Compare commits

...
Author SHA1 Message Date
Muhsin Keloth cf9fb84bd5 feat: add linear cta 2024-05-22 15:48:26 +05:30
Muhsin Keloth e847c34380 chore: review fixes 2024-05-22 15:03:10 +05:30
Sivin VargheseandGitHub 023d3e0821 Merge branch 'develop' into feature/cw-3271 2024-05-22 14:07:32 +05:30
Muhsin KelothandGitHub 023b3ad507 feat: Add APIs for linear integration (#9346) 2024-05-22 13:37:58 +05:30
Sivin VargheseandGitHub 0d13c11c44 fix: Right click Snooze is not working (#9498) 2024-05-22 13:03:49 +05:30
Muhsin KelothandGitHub 63e407b1c5 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-22 13:01:00 +05:30
Muhsin Keloth 717e744b08 Update linear.js 2024-05-22 13:00:50 +05:30
Muhsin Keloth aefe3a1b58 chore: rename linked_issues route 2024-05-22 12:57:53 +05:30
Muhsin KelothandGitHub eab8d8d0fb Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-22 12:13:41 +05:30
Muhsin Keloth dd78655181 chore: review fixes 2024-05-22 11:59:40 +05:30
Muhsin KelothandGitHub 831ac02d82 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-22 08:56:18 +05:30
Muhsin KelothandGitHub a0d85557e4 Merge branch 'develop' into feat/linear-backend 2024-05-22 08:54:18 +05:30
Sojan JoseandGitHub db13049e6f fix: [Snyk] Security upgrade administrate-field-active_storage from 1.0.2 to 1.0.3 (#9496)
- Security upgrade administrate-field-active_storage from 1.0.2 to 1.0.3
2024-05-21 13:36:11 -07:00
PranavandGitHub 7a1a686133 fix: Fix typo in the font for portals (#9515)
The fonts on the portal were not loaded properly before due to a typo in
the stylesheet. The font that is shown on the public portal is ui-sans
right now. This PR fixes it.
2024-05-21 13:35:35 -07:00
Muhsin KelothandGitHub fb22659ec8 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-21 19:51:12 +05:30
Muhsin Keloth 23ac2d9e4b chore: remove unused code 2024-05-21 16:53:20 +05:30
Muhsin Keloth 497471632b Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-21 13:50:58 +05:30
Muhsin Keloth 903ec124f6 chore: add identifier in search query 2024-05-21 13:50:48 +05:30
Muhsin Keloth 902f3aa926 Merge branch 'feature/cw-3271' of https://github.com/chatwoot/chatwoot into feature/cw-3271 2024-05-21 13:50:40 +05:30
Muhsin Keloth 4b0345a577 chore: remove unwanted changes 2024-05-21 13:50:31 +05:30
Muhsin Keloth a2dab6a3a8 chore: remove unwanted changes 2024-05-21 12:42:07 +05:30
Muhsin KelothandGitHub d278107133 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-21 12:37:31 +05:30
Muhsin Keloth efb18c1b64 chore: add identifier in search query 2024-05-21 12:37:21 +05:30
Muhsin KelothandGitHub 4c86d56200 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-21 12:36:44 +05:30
Muhsin Keloth 70e1bf8d31 chore: cleanup 2024-05-21 12:36:19 +05:30
Muhsin Keloth e8f5e35dc9 chore: add title in link issue 2024-05-21 12:34:05 +05:30
Muhsin Keloth df0a7a348b chore: change link dropdown component 2024-05-20 17:05:07 +05:30
Muhsin KelothandGitHub 6550287f97 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-20 16:25:30 +05:30
Muhsin KelothandGitHub cfb1ae0dd1 Merge branch 'develop' into feat/linear-backend 2024-05-20 16:25:23 +05:30
Sivin VargheseandGitHub 00dca9466a feat: Move the SLA filter dropdown to UI folder (#9502)
# Pull Request Template

## Description

I moved the newly created SLA filter dropdown to the UI folder to use in
adding the Linear issue modal.
2024-05-20 16:23:42 +05:30
Muhsin Keloth 95d2a57a81 chore: add create comment api 2024-05-20 15:34:38 +05:30
e9831b8855 feat: add inbox reconnection banner (#9441)
![CleanShot 2024-05-09 at 12 44
07@2x](https://github.com/chatwoot/chatwoot/assets/18097732/2b2861d4-ddcb-481a-b411-f553c19ff573)

![CleanShot 2024-05-09 at 12 44
36@2x](https://github.com/chatwoot/chatwoot/assets/18097732/9cdb8213-acce-4499-a72b-1feba2b611a6)

---------

Co-authored-by: Fayaz Ahmed <fayazara@gmail.com>
2024-05-20 11:57:03 +05:30
Shivam MishraandGitHub f6650b5025 feat: move Azure config to installation_config (#9481)
This PR has the following changes

1. Add `AZURE_APP_ID` and `AZURE_APP_SECRET` to installation config
2. Add Microsoft config to `super_admin/features.yml`
3. Replace usage of `ENV.fetch` with `GlobalConfigService.load` for
fetch App ID and Secret
2024-05-20 11:52:42 +05:30
Muhsin Keloth 6b86b6f87d chore: refactor link issue related components 2024-05-20 10:55:00 +05:30
Muhsin Keloth b5d655171e chore: refactor issue component 2024-05-20 10:31:37 +05:30
Muhsin Keloth 4263ea7db1 chore: more cleanups 2024-05-20 10:07:55 +05:30
Muhsin Keloth 3552eede47 chore: more cleanups 2024-05-20 09:57:28 +05:30
Muhsin Keloth 24825a070a feat: change search component 2024-05-18 22:46:32 +05:30
Muhsin Keloth 7767c83206 Update IssueItem.vue 2024-05-18 12:34:30 +05:30
Muhsin Keloth 64324d239e chore: style changes 2024-05-18 12:08:22 +05:30
Muhsin KelothandGitHub bd55bb1f08 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-17 20:26:28 +05:30
Muhsin Keloth ca8dc3760f chore: update linear config 2024-05-17 20:26:09 +05:30
Muhsin Keloth 6471db4ba7 chore: code cleanup 2024-05-17 20:23:55 +05:30
Muhsin Keloth d7e7e4d8c9 Update IssueItem.vue 2024-05-17 16:49:22 +05:30
Muhsin KelothandGitHub d169d97987 Merge branch 'develop' into feat/linear-backend 2024-05-17 16:45:03 +05:30
Muhsin Keloth 480888e115 chore: unused image 2024-05-17 16:20:03 +05:30
Muhsin Keloth 1fb84c6402 chore: change everything to vue3 syntax 2024-05-17 16:09:08 +05:30
Muhsin Keloth 2c2e000264 Merge branch 'feature/cw-3271' of https://github.com/chatwoot/chatwoot into feature/cw-3271 2024-05-17 16:08:43 +05:30
Shivam MishraandGitHub 00a460db43 fix: fetch FB_APP_ID from @global_config (#9483) 2024-05-17 15:44:50 +05:30
Muhsin Keloth 3376183b03 chore: migrate linear base to vue3 syntax 2024-05-17 15:14:38 +05:30
Muhsin KelothandGitHub 8159aad772 Merge branch 'feat/linear-backend' into feature/cw-3271 2024-05-17 15:01:26 +05:30
Muhsin Keloth c26e0cc7e0 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-17 15:01:09 +05:30
Muhsin Keloth 2378efd853 Update linear_queries.rb 2024-05-17 15:00:28 +05:30
Muhsin KelothandGitHub b40c3110aa Merge branch 'develop' into feat/linear-backend 2024-05-17 15:00:04 +05:30
Muhsin Keloth 3d1bafa3c0 feat: complete basic front end 2024-05-17 14:04:48 +05:30
Muhsin Keloth 08c487d1c3 chore: update issue query 2024-05-17 12:08:32 +05:30
Muhsin Keloth 0e11dac471 feat: create basic front end 2024-05-17 12:06:19 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
85dcb84675 chore(deps): bump rexml from 3.2.5 to 3.2.8 (#9489)
Bumps rexml from 3.2.5 to 3.2.8.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-16 18:19:22 -07:00
Sivin VargheseandGitHub a83b609025 fix: New account button style in switch account modal (#9456) 2024-05-16 22:33:43 +05:30
Muhsin KelothandGitHub e992c23d03 Merge branch 'feat/linear-backend' into feat/linear-frontend 2024-05-16 17:30:58 +05:30
Muhsin Keloth 669a138114 chore: team validation 2024-05-16 16:20:24 +05:30
Muhsin Keloth 31732a1a7b chore: replace graphlient with HTTParty 2024-05-16 16:18:47 +05:30
Muhsin KelothandGitHub b663f3201c Merge branch 'develop' into feat/linear-backend 2024-05-16 15:25:33 +05:30
Muhsin Keloth 01ddb61c9e chore: add url in issue 2024-05-16 15:25:10 +05:30
Sojan 528b2dc17c Merge branch 'release/3.9.0' into develop 2024-05-15 22:32:27 -07:00
Sojan 41e373ace3 Bump version to 3.9.0 2024-05-15 22:15:52 -07:00
Chatwoot BotandGitHub b0b4668d23 chore: Update translations (#9439)
- update translations from crowdin
2024-05-15 21:52:30 -07:00
Sojan JoseandGitHub a2d0e60a88 fix: Get online status from db when not present in cache [CW-3233] (#9477)
Previously, we returned the static value 'online' when the status was
not present in the Redis cache. This PR changes it to fall back to the
DB value and updates the cache in such cases.

fixes:
https://linear.app/chatwoot/issue/CW-3233/write-a-back-up-for-online-status-in-case-if-redis-keys-are-not
2024-05-15 21:23:19 -07:00
Sojan JoseandGitHub 7b83480979 chore: Add indexes to improve reporting performance (#9478)
- Adding a new index on (account_id,created_at,message_type) based on
our performance improvement exercise. This index significantly improves
the page load speeds of messaging reports.
2024-05-15 21:21:15 -07:00
Sivin VargheseandGitHub 5657473573 fix: Dashboard phone number input country undefined in onSelectCountry (#9473)
# Pull Request Template

## Description

This PR will fix this sentry
[issue](https://chatwoot-p3.sentry.io/issues/5291039795/)

**Issue**
The root cause of this issue is the usage of
`keyboardEventListenerMixins`. The key events are always active when the
edit conversation modal is active, even if the country dropdown is not
visible. So, if we press the enter key, this error will be thrown into
the console.

**Solution**
Remove the use of `keyboardEventListenerMixins` and handle it directly
in the Vue native key events. Also, always check if the dropdown is
active.

**Other changes**
1. Remove the `mouseup` event lister and use the click away directive.
2. Use inline Tailwind css


Fixes
https://linear.app/chatwoot/issue/CW-3282/phonenumberinput-country-undefined-in-onselectcountry

## Type of change

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

## How Has This Been Tested?

**Steps**
1. Open a conversation.
3. And click the edit contact button
4. And click the enter key
5. Now you can see the error in the console


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2024-05-16 09:16:02 +05:30
ae5ef73e91 fix: Update the voice note format to MP3 to fix the delivery issues (#9448)
Use MP3 as the default format to send voice notes recorded from
Chatwoot. This change was made to fix the issue of Telegram voice notes
not working with the error `WEBPAGE_CURL_FAILED` .

Telegram treats the mp3 recordings as audio attachments. Once we can
identify a fix for the original issue, we will revisit the `ogg`
implementation.

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-05-15 17:53:45 -07:00
Sojan JoseandGitHub 8520846b91 chore: Improved indexes for Conversations & Contacts [CW-3300] (#9475)
Based on our recent performant optimisation exercises, We have identified a better indexing strategy for conversations and contacts. The previous index on last_activity_at for conversations significantly slowed down conversation filters. Similarly, the new index on Contacts will allow the page rendering to improve for accounts with many contacts.

fixes: https://linear.app/chatwoot/issue/CW-3300/db-improvements
2024-05-15 16:10:39 -07:00
Sojan JoseandGitHub 7ed375f6f5 chore: Show valid error messages on Inbox creation (#9474)
At the moment, when creating an inbox for Whatsapp, Telegram, etc., we show a generic error message saying that inbox creation failed. This PR will show the error messages directly from the API call, which is more helpful as it says if the error is due to the provided credentials.
2024-05-15 15:53:41 -07:00
fc1c992cde fix: [Snyk] Security upgrade devise_token_auth from 1.2.1 to 1.2.3 (#9468)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-ACTIONCABLE-20338
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-05-15 11:52:40 -07:00
Shivam MishraandGitHub bc8736c08e fix: widget does not load when navigating on pages with view transition [CW-3249] (#9443)
* feat: add ids to each element

* feat: restore elements for apps that use view transitions

* fix: remove generator check condition

* feat: handle turbolinks

* fix: new body handling

* chore: undo debug changes
2024-05-15 10:45:03 +05:30
Sivin VargheseandGitHub 5a289776de fix: Widget phone number input country undefined in onSelectCountry (#9457)
* fix: Widget phone number input country undefined in onSelectCountry

* chore: Minor fix
2024-05-15 09:48:55 +05:30
Sojan JoseandGitHub e98e27dc1f chore: Make IP_LOOKUP_BASE_URL configurable (#9467)
Since we download the GeoIP database during worker/server initialization, there is a high chance of spamming the server with too many requests for downloads, especially if the number of web and worker nodes is high. This PR provides the ability to specify a custom URL for the GeoLite database download, configurable via an environment variable. This helps in distributing the load and avoiding server overload during the initialization process
2024-05-14 14:32:17 -07:00
Sojan JoseandGitHub d54492f7b5 chore: Add debug statement in spec (#9466)
- Add a debug statement to check the failed specs
2024-05-14 14:19:02 -07:00
Muhsin Keloth 87e8e342c9 Update index.vue 2024-05-14 12:27:52 +05:30
Muhsin Keloth 27ec845faa chore: add url in issue 2024-05-14 08:28:24 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1d4798a3bf chore(deps): bump nokogiri from 1.16.4 to 1.16.5 (#9459)
Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.16.4 to 1.16.5.
- [Release notes](https://github.com/sparklemotion/nokogiri/releases)
- [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.16.4...v1.16.5)

---
updated-dependencies:
- dependency-name: nokogiri
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-13 18:12:02 -07:00
e992283993 fix: [Snyk] Security upgrade omniauth-rails_csrf_protection from 1.0.1 to 1.0.2 (#9454)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-05-13 16:07:56 -07:00
Sojan JoseandGitHub 07e33fd98a chore: Switch models to gpt-4o (#9458)
- Switch model to gpt-4o from gpt-4-turbo
2024-05-13 13:32:11 -07:00
iamsivin 91cefe2072 chore: Fix input styles 2024-05-14 00:55:54 +05:30
Muhsin KelothandGitHub d356067300 Merge branch 'develop' into feat/linear-backend 2024-05-13 22:06:57 +05:30
Muhsin Keloth aa3f2b56bc chore: minor fix 2024-05-13 21:08:59 +05:30
Muhsin Keloth bc5449f208 chore: translation updates 2024-05-13 19:13:03 +05:30
Muhsin Keloth e1bfd9a17a chore: translation updates 2024-05-13 19:04:06 +05:30
Muhsin Keloth 4825ab1a29 chore: code cleanup 2024-05-13 18:34:40 +05:30
iamsivin 7efc2b9325 chore: Style fix 2024-05-13 14:33:01 +05:30
Muhsin Keloth 4a491d8a91 feat: add or link issue 2024-05-13 13:59:38 +05:30
Sojan JoseandGitHub 9a8442fe0e chore: Handle conversation participation creation race condition error (#9449)
We observed some race condition errors in the conversation participation listener while trying to create a conversation participation assignment. This PR handles this error and also adds additional debug information for future.

fixes: https://linear.app/chatwoot/issue/CW-3296/activerecordrecordnotunique-pguniqueviolation-error-duplicate-key

## Changelog

- handles `ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvald` errors so that they won't pollute sentry
- Adds a debug statement to log the cases
- Add previous_changes into the dispatcher so that we know the exact attribute changes which trigger `assignee_changed, team_changed` events ( would be handy in future )
2024-05-10 12:21:23 -07:00
Vishnu NarayananandGitHub dd1f93d425 feat: Switch Heroku Postgres basic to essential-0 (#9452) 2024-05-10 09:52:04 -07:00
Muhsin Keloth f2db469c75 feat: add basic create issue feature 2024-05-10 16:59:41 +05:30
Muhsin Keloth 5d48c2b1e5 chore: add more fields in get issue querey 2024-05-10 16:53:01 +05:30
Muhsin Keloth ecce6e021b chore: update link issue API 2024-05-10 15:50:13 +05:30
Muhsin Keloth 4d47fc1c25 chore: rename from team_entites to team_entities 2024-05-10 15:16:56 +05:30
Muhsin KelothandGitHub b25e395bf7 Merge branch 'develop' into feat/linear-backend 2024-05-10 11:40:56 +05:30
Shivam MishraandGitHub a4001374a5 feat: add missing clickaway directives (#9451)
* feat: add onClickaway to widget

* feat: add clickaway to portal

* feat: add directive to PublicArticleSearch
2024-05-10 11:15:36 +05:30
Muhsin KelothandGitHub 12b3323364 Merge branch 'develop' into feat/linear-backend 2024-05-10 09:44:59 +05:30
Sojan JoseandGitHub 4284c123a6 chore: Handle invalid email address in IMAP channel (#9450) 2024-05-10 08:55:26 +05:30
Muhsin KelothandGitHub 793420c341 Merge branch 'develop' into feat/linear-backend 2024-05-09 23:41:57 +05:30
Shivam MishraandGitHub 05f0398b86 fix: Update message signature default value (#9446) 2024-05-09 10:55:30 -07:00
Muhsin KelothandGitHub 775e8251a6 Merge branch 'develop' into feat/linear-backend 2024-05-09 22:45:43 +05:30
b110ff5e00 chore: Upgrade @chatwoot/utils (#9445)
chore: upgrade utils

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-05-09 22:42:05 +05:30
Muhsin Keloth 4645e57e9a feat: add linked issue api 2024-05-09 22:40:42 +05:30
Muhsin Keloth 5e1dbfb64d feat: search issue API 2024-05-09 22:28:47 +05:30
Muhsin Keloth df29b43540 feat: unlink issue api 2024-05-09 21:44:42 +05:30
5138a0ad32 feat: Adds support for all snooze option in bulk actions (#9361)
* feat: Add support for bulk snooze until

* feat: Adds support for all snooze option in bulk actions

* chore: Adds comment

* chore: Review fixes

* chore: Minor fix

* chore: Minor fix

* chore: Review fixes

* chore: yarn changes

* fix: terminal waring

* chore: Adds spec

* Update conversationHotKeys.js

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-05-09 19:27:31 +05:30
Muhsin Keloth 3e4f43d7da chore: add specs for create and link apis 2024-05-09 16:24:31 +05:30
Muhsin Keloth 2b7c972cbf feat: add specs for linear processor 2024-05-09 15:16:14 +05:30
Muhsin Keloth 429cfd0166 feat: add specs for create and link issue 2024-05-09 15:12:20 +05:30
Muhsin Keloth fc41669178 feat: add link issue API 2024-05-09 15:05:36 +05:30
Muhsin Keloth e567be8fb2 chore: issue input validation 2024-05-09 14:25:00 +05:30
Muhsin Keloth 1ddc05dadb feat: add create issue API 2024-05-09 12:23:14 +05:30
Muhsin KelothandGitHub 0268d544d1 Merge branch 'develop' into feat/linear-backend 2024-05-09 11:33:07 +05:30
Shivam MishraandGitHub eff24c0d71 feat: allow feature plan map in super admin (#9318)
- Add subscribed_features method in models/enterprise/account and include it in the JSON response
2024-05-08 22:58:46 -07:00
Muhsin KelothandGitHub 2960bcdf7d Merge branch 'develop' into feat/linear-backend 2024-05-09 10:37:54 +05:30
Toan LeandGitHub 4eec0aa11a fix: Profile pictures missing in facebook inbox [CW-1976] (#9212)
The ActiveJob FacebookEventsJob created a Contact and then enqueued Avatar::AvatarFromUrlJob in another process. However, since the Contact was created within a transaction when AvatarFromUrlJob was executed immediately afterwards, the Contact was not actually present in the database then.

Fixes: #6138 #6761
2024-05-08 20:38:16 -07:00
Muhsin KelothandGitHub 3c4a7bb9f7 Merge branch 'develop' into feat/linear-backend 2024-05-09 07:32:51 +05:30
Sojan JoseandGitHub 3a1299520c chore: Enable plan reconcile in self hosted (#9357)
This PR will revert the premium config in self hosted instances to its original state if any unauthorised modifications are made.
2024-05-08 14:26:00 -07:00
Shivam MishraandGitHub 73f373913f fix: Add fallback for created_by and updated_by attributes in Macro (#9436) 2024-05-08 13:40:07 -07:00
Shivam MishraandGitHub 6c5b137dba fix: Add Safari compatibility for requestIdleCallback (#9435) 2024-05-08 13:28:27 -07:00
Muhsin Keloth 792bc5cf9d Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 13:46:43 +05:30
Muhsin Keloth bf40242497 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 13:46:35 +05:30
Muhsin Keloth 2130a2ebe1 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 12:36:54 +05:30
Muhsin KelothandGitHub 2bad7f435a Merge branch 'develop' into feat/linear-backend 2024-05-08 12:36:43 +05:30
Muhsin Keloth c0d985a62b chore: make applications component in vue 3 syntax 2024-05-08 12:36:33 +05:30
Muhsin Keloth 5b7af73abe Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-08 12:15:44 +05:30
Muhsin Keloth 2ae79bd9a1 chore: add specs 2024-05-08 12:15:38 +05:30
PranavandGitHub 9977bcc093 fix: Convert audio file to mp3 before sending it to WhatsApp (#9432)
Last week, the WhatsApp API has started returning an error message for the voice notes, Unsupported Audio mime type audio/opus. Please use one of audio/ogg; codecs=opus, audio/mpeg, audio/amr, audio/mp4, audio/aac. This error began appearing on May 1. Even though there was no change in the files and content type, FB API started rejecting the file.

In this PR, we are converting the audio recordings from Wav to Mp3 from frontend itself.
2024-05-07 23:10:54 -07:00
Muhsin Keloth 17b61ad2b4 chore: add specs 2024-05-08 10:51:10 +05:30
Muhsin KelothandGitHub e098e463ad Merge branch 'develop' into feat/linear-backend 2024-05-08 09:09:13 +05:30
Muhsin KelothandGitHub d649bd2dfd feat: Add support for bulk snooze until (#9360) 2024-05-08 08:55:31 +05:30
2ef767d60f feat: exporting contacts takes the filters into account (#9347)
- This PR allows contacts to be exported using the current filter in CRM view

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2024-05-07 20:18:36 -07:00
de044e29f0 chore: Rescue Oauth2::Error and mark the channel as inactive (#9358)
Co-authored-by: Sojan <sojan@pepalo.com>
2024-05-07 12:03:38 -07:00
Sojan JoseandGitHub 892e1fd333 chore:Remove the official support for 360Dialog APIs (#9356) 2024-05-07 11:37:28 -07:00
Muhsin Keloth b11f4d5bd5 Update linear_controller.rb 2024-05-07 15:22:59 +05:30
Muhsin Keloth 0a45b7e156 chore: add feature flag linear_integration 2024-05-07 15:13:33 +05:30
Muhsin Keloth ee184d9792 chore: cleanup code 2024-05-07 14:19:34 +05:30
Muhsin KelothandGitHub 3595903fb5 Merge branch 'develop' into feat/linear-backend 2024-05-07 14:06:03 +05:30
Shivam MishraandGitHub 4c1c856562 chore: Remove word spacing from p (#9353) 2024-05-06 18:27:56 -07:00
Sojan JoseandGitHub f6d7f3b665 fix: assignee_changed callback not getting triggered during conversation creation (#9334)
The reload method in our callback was refreshing the object and hence the saved_change_to_assignee_id? Method wasn't working in the following callbacks.

This impacted the listeners subscribing to the event `ASSIGNEE_CHANGE`, `TEAM_CHANGE` etc
2024-05-06 11:48:17 -07:00
Muhsin KelothandGitHub 68a7fce45c Merge branch 'develop' into feat/linear-backend 2024-05-06 22:35:00 +05:30
2af0d58deb feat: Revamp profile settings screen (#9352)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2024-05-06 22:33:39 +05:30
Shivam MishraandGitHub 046c434103 chore: run update-browserslist-db (#9351)
chore: upgrade caniuse db
2024-05-06 19:19:40 +05:30
Muhsin KelothandGitHub 8e5cabd14d Merge branch 'develop' into feat/linear-backend 2024-05-06 16:31:59 +05:30
Shivam MishraandGitHub 64283b35a5 refactor: Use processed message content for slack (#9349) 2024-05-06 16:31:20 +05:30
Muhsin Keloth dd2938dc03 Merge branch 'feat/linear-backend' of https://github.com/chatwoot/chatwoot into feat/linear-backend 2024-05-06 15:58:56 +05:30
Muhsin Keloth 0bf4fc9e21 chore: add ProcessorService spec 2024-05-06 15:58:53 +05:30
Muhsin KelothandGitHub 8f7162d6c1 Merge branch 'develop' into feat/linear-backend 2024-05-06 14:27:21 +05:30
Muhsin Keloth 243834c401 feat: add team entities API 2024-05-06 14:26:52 +05:30
Muhsin KelothandGitHub 0914d4d2c8 feat: Revamp browser alert permission in profile settings (#9350) 2024-05-06 12:45:59 +05:30
Muhsin KelothandGitHub 1bba61a758 Merge branch 'develop' into feat/linear-backend 2024-05-03 14:29:47 +05:30
Muhsin Keloth e58f1ad98d fix: specs 2024-05-03 14:25:10 +05:30
Shivam Mishra 7e61397186 fix: linear spec 2024-05-03 13:36:24 +05:30
Muhsin Keloth c5e34669f6 chore: add linear schema 2024-05-03 12:59:34 +05:30
Muhsin Keloth 97f96db60e Create linear_spec.rb 2024-05-03 12:48:14 +05:30
Muhsin KelothandGitHub 4ed13787d1 chore: Import SLA helpers from utils (#9252)
chore: Add SLA helper from utils
2024-05-03 11:13:32 +05:30
PranavandGitHub abbb087a53 feat: Use the font Inter Display for help center articles (#9239)
Use Inter Display for help center
2024-05-03 09:48:01 +05:30
jderechoandGitHub 0d7773d88f feat: Draft through API for Cross-Device Support (#8018)
- Add APIs for cross-device message drafts

ref: #6890
2024-05-02 18:42:18 -07:00
Shivam MishraandGitHub b24626a65a feat: Include resolution time and waiting time in team & agent report export (#9296) 2024-05-02 17:29:52 -07:00
Pavel MotyrevandGitHub 9e8a626598 doc: Ability to specify redis database number (#8231)
- Update documentation on how to specify Redis database number
2024-05-02 16:42:14 -07:00
f48cff847f fix: Prevent formData.append from Adding 'null' String for null replyTo (#8623)
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-05-02 15:30:46 -07:00
f57013c92f feat: Add an option to scroll to replied message (#8624)
Co-authored-by: Pranav <pranav@chatwoot.com>
2024-05-02 15:29:10 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cd14788e67 chore(deps): bump tar from 6.1.11 to 6.2.1 (#9220)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.1.11 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.1.11...v6.2.1)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-02 15:06:09 -07:00
5693a91fd3 feat: Gray out Microsoft email option when Azure App ID is not configured (#9087)
Fixes #9083 

Co-authored-by: Pranav <pranavrajs@gmail.com>
2024-05-02 15:05:08 -07:00
2769b03cc8 feat: Add ability to sort canned responses (#9330)
Co-authored-by: jeffOtano <jeff@rinsed.com>
2024-05-02 14:11:52 -07:00
Sojan JoseandGitHub ac93af6028 feat: Support document file uploads on telegram channel (#9266) 2024-05-02 14:05:14 -07:00
Muhsin KelothandGitHub 3488a315d0 fix: Add an option to view the masked information in the profile settings UI (#9343) 2024-05-02 13:55:40 -07:00
Sivin VargheseandGitHub 5846ee4bad fix: Email input style issue (#9342) 2024-05-02 21:32:45 +05:30
7f8ac37a30 feat: don't allow dropdown navigation on input focused (#9341)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-05-02 21:09:08 +05:30
Chatwoot BotandGitHub 94e1d55543 chore(i18n): Update the latest translations (#9340) 2024-05-02 08:18:17 -07:00
Muhsin Keloth 0fafe37646 chore: code cleanup 2024-05-02 16:45:57 +05:30
Muhsin Keloth 68cb6b19b0 feat: add teams end point 2024-05-02 16:11:21 +05:30
Muhsin Keloth ead7cc0c36 feat: add liner integration 2024-05-02 13:39:57 +05:30
PranavandGitHub af39308851 chore: Add a separate Sentry project for frontend error (#9336) 2024-05-01 12:06:19 -07:00
d34d726b37 chore(snyk): Security upgrade administrate-field-active_storage from 1.0.1 to 1.0.2 (#9332)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-ACTIONCABLE-20338
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-05-01 12:05:40 -07:00
c9a575c310 feat: Revamp access token section in profile settings (#9328)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 22:02:01 +05:30
e8475a9b2d fix: Switch to instagram CDN url for attachments (#9287)
Fixes: https://github.com/chatwoot/chatwoot/issues/8512

Co-authored-by: Denis Karlyuk <DevilDK@mail.ru>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-30 08:04:05 -07:00
Vishnu NarayananandGitHub 9057c865c3 feat: Add rake task to clear ActionCable jobs (#9307) 2024-04-30 08:03:40 -07:00
PranavandGitHub 2c3e7d6554 chore: Temporarily disable the disconnect logic for inboxes (#9321)
* chore: Temporarily disable the disconnect logic for inboxes

* Remove unused spec
2024-04-30 07:49:37 -07:00
Vishnu NarayananandGitHub 0856d76332 chore: Fix typos in inboxmgmt.json (#9326) 2024-04-30 07:49:23 -07:00
c92ea11eee feat: Revamp notification and audio preferences (#9312)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 16:50:00 +05:30
Sivin VargheseandGitHub 4fd8c7a61b fix: TypeError cannot set properties of undefined (setting 'keydownHandlerIndex') (#9327) 2024-04-30 15:20:05 +05:30
6917ea2dbd fix: portal settings layout (#9324)
* fix: padding

* fix: max width

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 10:57:22 +05:30
Sivin VargheseandGitHub 12af2fe026 feat: Remove the usage of clickAway mixin with directive (#9323) 2024-04-30 10:35:14 +05:30
705f8ef948 feat: add composables for migration and update portal settings (#9299)
* feat: setup vuelitdate for vue 2.7

* feat: add all composables

* fix: portal settings layout

* feat: remove styles

* feat: use setup API for ListAllCategories

* chore: format ListAllCategories

* refactor: add useAlert

* feat: add track composable

* feat: update map getters

* fix: import

* feat: update edit portal locales page [wip]

* feat: migrate locales page

* feat: remove alert message ref

* chore: format EditPortalLocales

* refactor: use composiiton api for PortalCustomization

* refactor: remove color

* feat: update PortalSettingsCustomizationForm to use setup syntax

* refactor: no need to import defineEmits

* refactor: format component

* fix: update logic

* feat: migrate PortalSettingsBasicForm

* refactor: format PortalSettingsBasicForm

* refactor: migrate EditPortalCustomization to Vue 2.7

* feat: migrate EditPortalBasic to vue 2.7

* chore: revert changes to EditPortal

* fix: portal layout

* fix: width

* feat: use setup syntax

* fix: double border

* feat: return track method

* refactor: track usage

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2024-04-30 10:13:50 +05:30
Sivin VargheseandGitHub 2012d00109 fix: Start calender is not moving properly is last 30 days range (#9322)
* fix: Start calender is not moving properly is last 30 days range

* chore: Minor fix
2024-04-30 10:09:08 +05:30
43b79aba9e feat: Revamp hotkeys and change password in profile settings (#9311)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 17:58:29 +05:30
47b1f610f1 feat: Adds helper text for custom fields in contact sidebars (#9317)
* feat: Adds helper text for custom fields in contact sidebars

* Update app/javascript/dashboard/components/ui/HelperTextPopup.vue

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>

* chore: Review fix

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 17:32:44 +05:30
8e9b21820e feat: setup composables for vue 2.7 (#9305)
* feat: setup vuelitdate for vue 2.7

* feat: add all composables

* feat: return track method

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2024-04-29 17:00:49 +05:30
c4eadd12ed feat: Revamp basic profile, avatar and message signature (#9310)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 16:41:55 +05:30
28728635c9 feat: Custom date picker (#9247)
* feat: Custom date picker

* chore: Calender footer

* chore: Minor fix

* chore: Reset date picker

* chore: Minor fix

* feat: Toggle button

* chore: Clean up

* chore: Use font inter

* chore: Cleanup and fix bugs

* fix: custom date range reset the calendar

* chore: fix logic bug

* feat: Add manual date range

* fix: styles in rtl

* chore: Helper specs

* chore: Clean up

* chore: Review fixes

* chore: remove magic strings

* chore: Add comments

* chore: Review fixes

* chore: Clean up

* chore: remove magic strings

* fix: Use outline instead of border

* chore: Minor style fix

* chore: disable pointer events for the disabled dates

* chore: Fix code climate

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2024-04-29 14:43:57 +05:30
a5ab8201c6 fix: [Snyk] Fix for 1 vulnerabilities (#9316)
fix: Gemfile & Gemfile.lock to reduce vulnerabilities

The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-SIDEKIQ-6689289

Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2024-04-29 14:24:06 +05:30
822 changed files with 13970 additions and 5502 deletions
+3
View File
@@ -23,6 +23,9 @@ FORCE_SSL=false
ENABLE_ACCOUNT_SIGNUP=false
# Redis config
# specify the configs via single URL or individual variables
# ref: https://www.iana.org/assignments/uri-schemes/prov/redis
# You can also use the following format for the URL: redis://:password@host:port/db_number
REDIS_URL=redis://redis:6379
# If you are using docker-compose, set this variable's value to be any string,
# which will be the password for the redis service running inside the docker-compose
+5 -5
View File
@@ -71,13 +71,13 @@ gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise', '>= 4.9.4'
gem 'devise-secure_password', git: 'https://github.com/chatwoot/devise-secure_password', branch: 'chatwoot'
gem 'devise_token_auth'
gem 'devise_token_auth', '>= 1.2.3'
# authorization
gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
@@ -113,10 +113,10 @@ gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.2.1'
gem 'sidekiq', '>= 7.2.4'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
@@ -166,7 +166,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
# adds cosine similarity to postgres using vector extension
+86 -82
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
actioncable (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
actionmailbox (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.1)
actionpack (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activesupport (= 7.0.8.1)
actionmailer (7.0.8.3)
actionpack (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activesupport (= 7.0.8.3)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.1)
actionview (= 7.0.8.1)
activesupport (= 7.0.8.1)
actionpack (7.0.8.3)
actionview (= 7.0.8.3)
activesupport (= 7.0.8.3)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.1)
actionpack (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
actiontext (7.0.8.3)
actionpack (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.1)
activesupport (= 7.0.8.1)
actionview (7.0.8.3)
activesupport (= 7.0.8.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.8.1)
activesupport (= 7.0.8.1)
activejob (7.0.8.3)
activesupport (= 7.0.8.3)
globalid (>= 0.3.6)
activemodel (7.0.8.1)
activesupport (= 7.0.8.1)
activerecord (7.0.8.1)
activemodel (= 7.0.8.1)
activesupport (= 7.0.8.1)
activemodel (7.0.8.3)
activesupport (= 7.0.8.3)
activerecord (7.0.8.3)
activemodel (= 7.0.8.3)
activesupport (= 7.0.8.3)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activesupport (= 7.0.8.1)
activestorage (7.0.8.3)
actionpack (= 7.0.8.3)
activejob (= 7.0.8.3)
activerecord (= 7.0.8.3)
activesupport (= 7.0.8.3)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.1)
activesupport (7.0.8.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.1)
administrate-field-active_storage (1.0.3)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -148,8 +148,9 @@ GEM
barnes (0.0.9)
multi_json (~> 1)
statsd-ruby (~> 1.1)
base64 (0.1.1)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.7)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -199,10 +200,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_token_auth (1.2.1)
devise_token_auth (1.2.3)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.1)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.0)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
@@ -368,7 +369,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.4)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -447,20 +448,20 @@ GEM
net-imap
net-pop
net-smtp
marcel (1.0.2)
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.0.0)
method_source (1.1.0)
mime-types (3.4.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.6)
minitest (5.22.3)
minitest (5.23.0)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -473,7 +474,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.9)
net-imap (0.4.11)
date
net-protocol
net-pop (0.1.2)
@@ -488,15 +489,15 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.7.0)
nokogiri (1.16.4)
nio4r (2.7.3)
nokogiri (1.16.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.4-arm64-darwin)
nokogiri (1.16.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-darwin)
nokogiri (1.16.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.4-x86_64-linux)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -523,7 +524,7 @@ GEM
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
@@ -568,20 +569,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.1)
actioncable (= 7.0.8.1)
actionmailbox (= 7.0.8.1)
actionmailer (= 7.0.8.1)
actionpack (= 7.0.8.1)
actiontext (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activemodel (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
rails (7.0.8.3)
actioncable (= 7.0.8.3)
actionmailbox (= 7.0.8.3)
actionmailer (= 7.0.8.3)
actionpack (= 7.0.8.3)
actiontext (= 7.0.8.3)
actionview (= 7.0.8.3)
activejob (= 7.0.8.3)
activemodel (= 7.0.8.3)
activerecord (= 7.0.8.3)
activestorage (= 7.0.8.3)
activesupport (= 7.0.8.3)
bundler (>= 1.15.0)
railties (= 7.0.8.1)
railties (= 7.0.8.3)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -589,9 +590,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
railties (7.0.8.3)
actionpack (= 7.0.8.3)
activesupport (= 7.0.8.3)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -603,7 +604,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.19.1)
redis-client (0.22.1)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -627,7 +628,8 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.2.5)
rexml (3.2.8)
strscan (>= 3.0.9)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
@@ -702,18 +704,19 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.14.0)
sentry-rails (5.17.3)
railties (>= 5.0)
sentry-ruby (~> 5.14.0)
sentry-ruby (5.14.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.14.0)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.2.1)
sidekiq (7.2.4)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
@@ -756,6 +759,7 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
@@ -817,7 +821,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.13)
zeitwerk (2.6.14)
PLATFORMS
arm64-darwin-20
@@ -835,7 +839,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -858,7 +862,7 @@ DEPENDENCIES
debug (~> 1.8)
devise (>= 4.9.4)
devise-secure_password!
devise_token_auth
devise_token_auth (>= 1.2.3)
dotenv-rails
down
elastic-apm
@@ -902,7 +906,7 @@ DEPENDENCIES
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0)
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
pg_search
pgvector
@@ -931,9 +935,9 @@ DEPENDENCIES
seed_dump
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.14.0)
sentry-sidekiq (>= 5.15.0)
shoulda-matchers
sidekiq (>= 7.2.1)
sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
+1 -1
View File
@@ -55,7 +55,7 @@
"plan": "heroku-redis:mini"
},
{
"plan": "heroku-postgresql:mini"
"plan": "heroku-postgresql:essential-0"
}
],
"stack": "heroku-20",
@@ -19,9 +19,9 @@ class ContactInboxWithContactBuilder
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
private
@@ -21,6 +21,6 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
end
def permitted_params
params.permit(:type, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
end
end
@@ -46,7 +46,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names, Current.user.email)
filter_params = { :payload => params.permit!['payload'], :label => params.permit!['label'] }
Account::ContactsExportJob.perform_later(Current.account.id, Current.user.id, column_names, filter_params)
head :ok, message: I18n.t('errors.contacts.export.success')
end
@@ -61,7 +62,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end
def filter
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
result = ::Contacts::FilterService.new(Current.account, Current.user, params.permit!).perform
contacts = result[:contacts]
@contacts_count = result[:count]
@contacts = fetch_contacts(contacts)
@@ -0,0 +1,28 @@
class Api::V1::Accounts::Conversations::DraftMessagesController < Api::V1::Accounts::Conversations::BaseController
def show
render json: { has_draft: false } and return unless Redis::Alfred.exists?(draft_redis_key)
draft_message = Redis::Alfred.get(draft_redis_key)
render json: { has_draft: true, message: draft_message }
end
def update
Redis::Alfred.set(draft_redis_key, draft_message_params)
head :ok
end
def destroy
Redis::Alfred.delete(draft_redis_key)
head :ok
end
private
def draft_redis_key
format(Redis::Alfred::CONVERSATION_DRAFT_MESSAGE, id: @conversation.id)
end
def draft_message_params
params.dig(:draft_message, :message) || ''
end
end
@@ -0,0 +1,93 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
def teams
teams = linear_processor_service.teams
if teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams[:data], status: :ok
end
end
def team_entities
team_id = permitted_params[:team_id]
team_entities = linear_processor_service.team_entities(team_id)
if team_entities[:error]
render json: { error: team_entities[:error] }, status: :unprocessable_entity
else
render json: team_entities[:data], status: :ok
end
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def linked_issues
issues = linear_processor_service.linked_issues(conversation_link)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
def search_issue
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
term = params[:q]
issues = linear_processor_service.search_issue(term)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
private
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
end
def fetch_conversation
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
end
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end
@@ -2,7 +2,10 @@ module MicrosoftConcern
extend ActiveSupport::Concern
def microsoft_client
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret,
{
site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
+1 -1
View File
@@ -58,7 +58,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
}
end
+1 -1
View File
@@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController
end
def microsoft_indentity
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
end
end
@@ -35,6 +35,8 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
+3 -2
View File
@@ -10,14 +10,15 @@ class AsyncDispatcher < BaseDispatcher
def listeners
[
AutomationRuleListener.instance,
CampaignListener.instance,
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
WebhookListener.instance,
AutomationRuleListener.instance
WebhookListener.instance
]
end
end
+11 -5
View File
@@ -1,7 +1,7 @@
module Api::V2::Accounts::ReportsHelper
def generate_agents_report
Current.account.users.map do |agent|
agent_report = generate_report({ type: :agent, id: agent.id })
agent_report = report_builder({ type: :agent, id: agent.id }).summary
[agent.name] + generate_readable_report_metrics(agent_report)
end
end
@@ -15,7 +15,7 @@ module Api::V2::Accounts::ReportsHelper
def generate_teams_report
Current.account.teams.map do |team|
team_report = generate_report({ type: :team, id: team.id })
team_report = report_builder({ type: :team, id: team.id }).summary
[team.name] + generate_readable_report_metrics(team_report)
end
end
@@ -27,7 +27,7 @@ module Api::V2::Accounts::ReportsHelper
end
end
def generate_report(report_params)
def report_builder(report_params)
V2::ReportBuilder.new(
Current.account,
report_params.merge(
@@ -37,7 +37,11 @@ module Api::V2::Accounts::ReportsHelper
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
)
).short_summary
)
end
def generate_report(report_params)
report_builder(report_params).short_summary
end
private
@@ -46,7 +50,9 @@ module Api::V2::Accounts::ReportsHelper
[
report_metric[:conversations_count],
Reports::TimeFormatPresenter.new(report_metric[:avg_first_response_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format
Reports::TimeFormatPresenter.new(report_metric[:avg_resolution_time]).format,
Reports::TimeFormatPresenter.new(report_metric[:reply_time]).format,
report_metric[:resolutions_count]
]
end
end
+2 -2
View File
@@ -77,8 +77,8 @@ class ContactAPI extends ApiClient {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
exportContacts() {
return axios.get(`${this.url}/export`);
exportContacts(queryPayload) {
return axios.post(`${this.url}/export`, queryPayload);
}
}
@@ -0,0 +1,46 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();
@@ -12,7 +12,6 @@ h6 {
p {
text-rendering: optimizeLegibility;
word-spacing: 0.12em;
@apply mb-2 leading-[1.65] text-sm;
@@ -1,9 +1,9 @@
<template>
<button
class="sm:w-[50%] md:w-1/3 lg:w-1/4 bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-[50%] my-4 mx-auto" />
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
@@ -7,79 +7,17 @@
]"
>
<slot />
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
<chat-list-header
:page-title="pageTitle"
:has-applied-filters="hasAppliedFilters"
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
@add-folders="onClickOpenAddFoldersModal"
@delete-folders="onClickOpenDeleteFoldersModal"
@filters-modal="onToggleAdvanceFiltersModal"
@reset-filters="resetAndFetchData"
@basic-filter-change="onBasicFilterChange"
/>
<add-custom-views
v-if="showAddFoldersModal"
@@ -173,6 +111,15 @@
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
@@ -180,8 +127,8 @@
import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
@@ -205,10 +152,15 @@ import {
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getUnixTime } from 'date-fns';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
ChatListHeader,
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
@@ -216,9 +168,9 @@ export default {
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
CustomSnoozeModal,
},
mixins: [
timeMixin,
@@ -288,7 +240,6 @@ export default {
foldersQuery: {},
showAddFoldersModal: false,
showDeleteFoldersModal: false,
selectedConversations: [],
selectedInboxes: [],
isContextMenuOpen: false,
appliedFilter: [],
@@ -296,6 +247,7 @@ export default {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
showCustomSnoozeModal: false,
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
@@ -329,12 +281,14 @@ export default {
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
selectedConversations: 'bulkActions/getSelectedConversationIds',
contextMenuChatId: 'getContextMenuChatId',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
},
hasActiveFolders() {
return this.activeFolder && this.foldersId !== 0;
return Boolean(this.activeFolder && this.foldersId !== 0);
},
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
@@ -558,6 +512,11 @@ export default {
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
updateVirtualListProps(key, value) {
@@ -799,7 +758,7 @@ export default {
});
},
resetBulkActions() {
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.selectedInboxes = [];
},
onBasicFilterChange(value, type) {
@@ -830,12 +789,16 @@ export default {
return this.selectedConversations.includes(id);
},
selectConversation(conversationId, inboxId) {
this.selectedConversations.push(conversationId);
this.$store.dispatch(
'bulkActions/setSelectedConversationIds',
conversationId
);
this.selectedInboxes.push(inboxId);
},
deSelectConversation(conversationId, inboxId) {
this.selectedConversations = this.selectedConversations.filter(
item => item !== conversationId
this.$store.dispatch(
'bulkActions/removeSelectedConversationIds',
conversationId
);
this.selectedInboxes = this.selectedInboxes.filter(
item => item !== inboxId
@@ -843,7 +806,10 @@ export default {
},
selectAllConversations(check) {
if (check) {
this.selectedConversations = this.conversationList.map(item => item.id);
this.$store.dispatch(
'bulkActions/setSelectedConversationIds',
this.conversationList.map(item => item.id)
);
this.selectedInboxes = this.conversationList.map(item => item.inbox_id);
} else {
this.resetBulkActions();
@@ -859,7 +825,7 @@ export default {
assignee_id: agent.id,
},
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
this.showAlert(
this.$t(
@@ -957,7 +923,7 @@ export default {
add: labels,
},
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
this.showAlert(
this.$t(
@@ -984,13 +950,13 @@ export default {
team_id: team.id,
},
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status) {
async onUpdateConversations(status, snoozedUntil) {
try {
await this.$store.dispatch('bulkActions/process', {
type: 'Conversation',
@@ -998,8 +964,9 @@ export default {
fields: {
status,
},
snoozed_until: snoozedUntil,
});
this.selectedConversations = [];
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
} catch (err) {
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
@@ -1026,6 +993,43 @@ export default {
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and then the custom snooze modal is open
// Then if the custom snooze modal is closed and set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
},
};
</script>
@@ -0,0 +1,115 @@
<script setup>
import { computed } from 'vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
});
const emits = defineEmits([
'add-folders',
'delete-folders',
'reset-filters',
'basic-filter-change',
'filters-modal',
]);
const onBasicFilterChange = (value, type) => {
emits('basic-filter-change', value, type);
};
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
</script>
<template>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emits('add-folders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emits('reset-filters')"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emits('filters-modal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emits('delete-folders')"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emits('filters-modal')"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
</template>
@@ -12,7 +12,7 @@
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full font-medium text-sm mb-0"
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
:class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
@@ -20,6 +20,11 @@
"
>
{{ label }}
<helper-text-popup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<woot-button
v-if="showCopyAndDeleteButton"
@@ -41,7 +46,7 @@
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:rounded-r-none rtl:rounded-l-none !mb-0 !text-sm"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: $v.editedValue.$error }"
@blur="$v.editedValue.$touch"
@@ -130,22 +135,25 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin, clickaway],
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
values: { type: Array, default: () => [] },
value: { type: [String, Number, Boolean], default: '' },
showActions: { type: Boolean, default: false },
@@ -0,0 +1,31 @@
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
<h4 v-if="title" class="text-lg font-medium text-ash-900">
{{ title }}
</h4>
<div class="flex flex-row items-center justify-between">
<div class="flex-grow h-px bg-ash-200" />
</div>
<p v-if="description" class="mb-0 text-sm font-normal text-ash-900">
{{ description }}
</p>
</div>
<div class="flex flex-col w-full gap-6">
<slot />
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
@@ -1,5 +1,5 @@
<template>
<div class="resolve-actions relative flex items-center justify-end">
<div class="relative flex items-center justify-end resolve-actions">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -73,26 +73,13 @@
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -100,23 +87,20 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SNOOZE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [clickaway, alertMixin, keyboardEventListenerMixins],
mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
};
},
computed: {
@@ -144,12 +128,10 @@ export default {
},
},
mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
@@ -202,28 +184,6 @@ export default {
// error
}
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
@@ -24,6 +24,7 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import DatePicker from './ui/DatePicker/DatePicker.vue';
const WootUIKit = {
AvatarUploader,
@@ -51,6 +52,7 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
DatePicker,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
@@ -47,7 +47,6 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -67,7 +66,7 @@ export default {
AvailabilityStatusBadge,
},
mixins: [clickaway, alertMixin],
mixins: [alertMixin],
data() {
return {
@@ -7,7 +7,7 @@
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="px-8 pt-4 pb-8">
<div class="px-8 py-4">
<div
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
@@ -45,10 +45,10 @@
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="flex justify-end items-center p-8 gap-2"
class="flex justify-end items-center px-8 pb-8 pt-4 gap-2"
>
<button
class="button success large expanded nice"
class="button success large expanded nice w-full"
@click="$emit('show-create-account-modal')"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
@@ -106,7 +106,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import Auth from '../../../api/auth';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
@@ -119,7 +118,6 @@ export default {
WootDropdownItem,
AvailabilityStatus,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,
@@ -0,0 +1,304 @@
<script setup>
import { ref, watch } from 'vue';
import {
getActiveDateRange,
moveCalendarDate,
DATE_RANGE_TYPES,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from './helpers/DatePickerHelper';
import {
isValid,
startOfMonth,
subDays,
startOfDay,
endOfDay,
isBefore,
subMonths,
addMonths,
isSameMonth,
differenceInCalendarMonths,
setMonth,
setYear,
isAfter,
} from 'date-fns';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
import CalendarYear from './components/CalendarYear.vue';
import CalendarMonth from './components/CalendarMonth.vue';
import CalendarWeek from './components/CalendarWeek.vue';
import CalendarFooter from './components/CalendarFooter.vue';
const { LAST_7_DAYS, LAST_30_DAYS, CUSTOM_RANGE } = DATE_RANGE_TYPES;
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const { WEEK, MONTH, YEAR } = CALENDAR_PERIODS;
const showDatePicker = ref(false);
const calendarViews = ref({ start: WEEK, end: WEEK });
const currentDate = ref(new Date());
const selectedStartDate = ref(startOfDay(subDays(currentDate.value, 6))); // LAST_7_DAYS
const selectedEndDate = ref(endOfDay(currentDate.value));
// Setting the start and end calendar
const startCurrentDate = ref(startOfDay(selectedStartDate.value));
const endCurrentDate = ref(
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedEndDate.value, 1)) // Moves to the start of the next month if dates are in the same month (Mounted case LAST_7_DAYS)
: startOfMonth(selectedEndDate.value) // Always shows the month of the end date starting from the first (Mounted case LAST_7_DAYS)
);
const selectingEndDate = ref(false);
const selectedRange = ref(LAST_7_DAYS);
const hoveredEndDate = ref(null);
const manualStartDate = ref(selectedStartDate.value);
const manualEndDate = ref(selectedEndDate.value);
const emit = defineEmits(['change']);
// Watcher will set the start and end dates based on the selected range
watch(selectedRange, newRange => {
if (newRange !== CUSTOM_RANGE) {
// If selecting a range other than last 7 days or last 30 days, set the start and end dates to the selected start and end dates
// If selecting last 7 days or last 30 days is, set the start date to the selected start date
// and the end date to one month ahead of the start date if the start date and end date are in the same month
// Otherwise set the end date to the selected end date
const isLastSevenOrThirtyDays =
newRange === LAST_7_DAYS || newRange === LAST_30_DAYS;
startCurrentDate.value = selectedStartDate.value;
endCurrentDate.value =
isLastSevenOrThirtyDays &&
isSameMonth(selectedStartDate.value, selectedEndDate.value)
? startOfMonth(addMonths(selectedStartDate.value, 1))
: selectedEndDate.value;
selectingEndDate.value = false;
} else if (!selectingEndDate.value) {
// If selecting a custom range and not selecting an end date, set the start date to the selected start date
startCurrentDate.value = startOfDay(currentDate.value);
}
});
// Watcher will set the input values based on the selected start and end dates
watch(
[selectedStartDate, selectedEndDate],
([newStart, newEnd]) => {
if (isValid(newStart)) {
manualStartDate.value = newStart;
} else {
manualStartDate.value = selectedStartDate.value;
}
if (isValid(newEnd)) {
manualEndDate.value = newEnd;
} else {
manualEndDate.value = selectedEndDate.value;
}
},
{ immediate: true }
);
// Watcher to ensure dates are always in logical order
// This watch is will ensure that the start date is always before the end date
watch(
[startCurrentDate, endCurrentDate],
([newStart, newEnd], [oldStart, oldEnd]) => {
const monthDifference = differenceInCalendarMonths(newEnd, newStart);
if (newStart !== oldStart) {
if (isAfter(newStart, newEnd) || monthDifference === 0) {
// Adjust the end date forward if the start date is adjusted and is after the end date or in the same month
endCurrentDate.value = addMonths(newStart, 1);
}
}
if (newEnd !== oldEnd) {
if (isBefore(newEnd, newStart) || monthDifference === 0) {
// Adjust the start date backward if the end date is adjusted and is before the start date or in the same month
startCurrentDate.value = subMonths(newEnd, 1);
}
}
},
{ immediate: true, deep: true }
);
const setDateRange = range => {
selectedRange.value = range.value;
const { start, end } = getActiveDateRange(range.value, currentDate.value);
selectedStartDate.value = start;
selectedEndDate.value = end;
};
const moveCalendar = (calendar, direction, period = MONTH) => {
const { start, end } = moveCalendarDate(
calendar,
startCurrentDate.value,
endCurrentDate.value,
direction,
period
);
startCurrentDate.value = start;
endCurrentDate.value = end;
};
const selectDate = day => {
selectedRange.value = CUSTOM_RANGE;
if (!selectingEndDate.value || day < selectedStartDate.value) {
selectedStartDate.value = day;
selectedEndDate.value = null;
selectingEndDate.value = true;
} else {
selectedEndDate.value = day;
selectingEndDate.value = false;
}
};
const setViewMode = (calendar, mode) => {
selectedRange.value = CUSTOM_RANGE;
calendarViews.value[calendar] = mode;
};
const openCalendar = (index, calendarType, period = MONTH) => {
const current =
calendarType === START_CALENDAR
? startCurrentDate.value
: endCurrentDate.value;
const newDate =
period === MONTH
? setMonth(startOfMonth(current), index)
: setYear(current, index);
if (calendarType === START_CALENDAR) {
startCurrentDate.value = newDate;
} else {
endCurrentDate.value = newDate;
}
setViewMode(calendarType, period === MONTH ? WEEK : MONTH);
};
const updateManualInput = (newDate, calendarType) => {
if (calendarType === START_CALENDAR) {
selectedStartDate.value = newDate;
startCurrentDate.value = newDate;
} else {
selectedEndDate.value = newDate;
endCurrentDate.value = newDate;
}
selectingEndDate.value = false;
};
const handleManualInputError = message => {
bus.$emit('newToastMessage', message);
};
const resetDatePicker = () => {
startCurrentDate.value = startOfDay(currentDate.value); // Resets to today at start of the day
endCurrentDate.value = addMonths(startOfDay(currentDate.value), 1); // Resets to one month ahead
selectedStartDate.value = startOfDay(subDays(currentDate.value, 6));
selectedEndDate.value = endOfDay(currentDate.value);
selectingEndDate.value = false;
selectedRange.value = LAST_7_DAYS;
// Reset view modes if they are being used to toggle between different calendar views
calendarViews.value = { start: WEEK, end: WEEK };
};
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
bus.$emit('newToastMessage', 'Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
}
};
</script>
<template>
<div class="relative font-inter">
<DatePickerButton
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selected-range="selectedRange"
@open="showDatePicker = !showDatePicker"
/>
<div
v-if="showDatePicker"
class="flex absolute top-9 ltr:left-0 rtl:right-0 z-30 shadow-md select-none w-[880px] h-[490px] rounded-2xl border border-slate-50 dark:border-slate-800 bg-white dark:bg-slate-800"
>
<CalendarDateRange
:selected-range="selectedRange"
@set-range="setDateRange"
/>
<div
class="flex flex-col w-[680px] ltr:border-l rtl:border-r border-slate-50 dark:border-slate-700/50"
>
<div class="flex justify-around h-fit">
<!-- Calendars for Start and End Dates -->
<div
v-for="calendar in [START_CALENDAR, END_CALENDAR]"
:key="`${calendar}-calendar`"
class="flex flex-col items-center"
>
<CalendarDateInput
:calendar-type="calendar"
:date-value="
calendar === START_CALENDAR ? manualStartDate : manualEndDate
"
:compare-date="
calendar === START_CALENDAR ? manualEndDate : manualStartDate
"
:is-disabled="selectedRange !== CUSTOM_RANGE"
@update="
calendar === START_CALENDAR
? (manualStartDate = $event)
: (manualEndDate = $event)
"
@validate="updateManualInput($event, calendar)"
@error="handleManualInputError($event)"
/>
<div class="py-5 border-b border-slate-50 dark:border-slate-700/50">
<div
class="flex flex-col items-center gap-2 px-5 min-w-[340px] max-h-[352px]"
:class="
calendar === START_CALENDAR &&
'ltr:border-r rtl:border-l border-slate-50 dark:border-slate-700/50'
"
>
<CalendarYear
v-if="calendarViews[calendar] === YEAR"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-year="openCalendar($event, calendar, YEAR)"
/>
<CalendarMonth
v-else-if="calendarViews[calendar] === MONTH"
:calendar-type="calendar"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
@select-month="openCalendar($event, calendar)"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev', YEAR)"
@next="moveCalendar(calendar, 'next', YEAR)"
/>
<CalendarWeek
v-else-if="calendarViews[calendar] === WEEK"
:calendar-type="calendar"
:current-date="currentDate"
:start-current-date="startCurrentDate"
:end-current-date="endCurrentDate"
:selected-start-date="selectedStartDate"
:selected-end-date="selectedEndDate"
:selecting-end-date="selectingEndDate"
:hovered-end-date="hoveredEndDate"
@update-hovered-end-date="hoveredEndDate = $event"
@select-date="selectDate"
@set-view="setViewMode"
@prev="moveCalendar(calendar, 'prev')"
@next="moveCalendar(calendar, 'next')"
/>
</div>
</div>
</div>
</div>
<CalendarFooter @change="emitDateRange" @clear="resetDatePicker" />
</div>
</div>
</div>
</template>
@@ -0,0 +1,79 @@
<script setup>
import { CALENDAR_PERIODS } from '../helpers/DatePickerHelper';
defineProps({
calendarType: {
type: String,
default: 'start',
},
firstButtonLabel: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
viewMode: {
type: String,
default: '',
},
});
const emit = defineEmits(['prev', 'next', 'set-view']);
const { YEAR } = CALENDAR_PERIODS;
const onClickPrev = type => {
emit('prev', type);
};
const onClickNext = type => {
emit('next', type);
};
const onClickSetView = (type, mode) => {
emit('set-view', type, mode);
};
</script>
<template>
<div class="flex items-start justify-between w-full h-9">
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickPrev(calendarType)"
>
<fluent-icon
icon="chevron-left"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
<div class="flex items-center gap-1">
<button
v-if="firstButtonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50 hover:text-woot-600 dark:hover:text-woot-600"
@click="onClickSetView(calendarType, viewMode)"
>
{{ firstButtonLabel }}
</button>
<button
v-if="buttonLabel"
class="p-0 text-sm font-medium text-center text-slate-800 dark:text-slate-50"
:class="{ 'hover:text-woot-600 dark:hover:text-woot-600': viewMode }"
@click="onClickSetView(calendarType, YEAR)"
>
{{ buttonLabel }}
</button>
</div>
<button
class="p-1 rounded-lg hover:bg-slate-75 dark:hover:bg-slate-700/50 rtl:rotate-180"
@click="onClickNext(calendarType)"
>
<fluent-icon
icon="chevron-right"
size="14"
class="text-slate-900 dark:text-slate-50"
/>
</button>
</div>
</template>
@@ -0,0 +1,74 @@
<script setup>
import { computed } from 'vue';
import { parse, isValid, isAfter, isBefore } from 'date-fns';
import {
getIntlDateFormatForLocale,
CALENDAR_TYPES,
} from '../helpers/DatePickerHelper';
const props = defineProps({
calendarType: {
type: String,
default: '',
},
dateValue: Date,
compareDate: Date,
isDisabled: Boolean,
});
const emit = defineEmits(['update', 'validate', 'error']);
const { START_CALENDAR, END_CALENDAR } = CALENDAR_TYPES;
const dateFormat = computed(() => getIntlDateFormatForLocale()?.toUpperCase());
const localDateValue = computed({
get: () => props.dateValue?.toLocaleDateString(navigator.language) || '',
set: newValue => {
const format = getIntlDateFormatForLocale();
const parsedDate = parse(newValue, format, new Date());
if (isValid(parsedDate)) {
emit('update', parsedDate);
}
},
});
const validateDate = () => {
if (!isValid(props.dateValue)) {
emit('error', `Please enter the date in valid format: ${dateFormat.value}`);
return;
}
const { calendarType, compareDate, dateValue } = props;
const isStartCalendar = calendarType === START_CALENDAR;
const isEndCalendar = calendarType === END_CALENDAR;
if (compareDate && isStartCalendar && isAfter(dateValue, compareDate)) {
emit('error', 'Start date must be before the end date.');
} else if (compareDate && isEndCalendar && isBefore(dateValue, compareDate)) {
emit('error', 'End date must be after the start date.');
} else {
emit('validate', dateValue);
}
};
</script>
<template>
<div class="h-[82px] flex flex-col items-start px-5 gap-1.5 pt-4 w-full">
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{
calendarType === START_CALENDAR
? $t('DATE_PICKER.DATE_RANGE_INPUT.START')
: $t('DATE_PICKER.DATE_RANGE_INPUT.END')
}}
</span>
<input
v-model="localDateValue"
type="text"
class="reset-base border bg-slate-25 dark:bg-slate-900 ring-offset-ash-900 border-slate-50 dark:border-slate-700/50 w-full disabled:text-slate-200 dark:disabled:text-slate-700 disabled:cursor-not-allowed text-slate-800 dark:text-slate-50 px-1.5 py-1 text-sm rounded-xl h-10"
:placeholder="dateFormat"
:disabled="isDisabled"
@keypress.enter="validateDate"
/>
</div>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { dateRanges } from '../helpers/DatePickerHelper';
defineProps({
selectedRange: {
type: String,
default: '',
},
});
const emit = defineEmits(['set-range']);
const setDateRange = range => {
emit('set-range', range);
};
</script>
<template>
<div class="w-[200px] flex flex-col items-start">
<h4
class="w-full px-5 py-4 text-sm font-medium capitalize text-start text-slate-600 dark:text-slate-200"
>
{{ $t('DATE_PICKER.DATE_RANGE_OPTIONS.TITLE') }}
</h4>
<div class="flex flex-col items-start w-full">
<button
v-for="range in dateRanges"
:key="range.label"
class="w-full px-5 py-3 text-sm font-medium truncate border-none rounded-none text-start hover:bg-slate-50 dark:hover:bg-slate-700"
:class="
range.value === selectedRange
? 'text-slate-800 dark:text-slate-50 bg-slate-50 dark:bg-slate-700'
: 'text-slate-600 dark:text-slate-200'
"
@click="setDateRange(range)"
>
{{ $t(range.label) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,28 @@
<script setup>
const emit = defineEmits(['clear', 'apply']);
const onClickClear = () => {
emit('clear');
};
const onClickApply = () => {
emit('change');
};
</script>
<template>
<div class="h-[56px] flex justify-between px-5 py-3 items-center">
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-slate-600 dark:text-slate-200 hover:text-slate-800 dark:hover:text-slate-100"
@click="onClickClear"
>
{{ $t('DATE_PICKER.CLEAR_BUTTON') }}
</button>
<button
class="p-1.5 rounded-lg w-fit text-sm font-medium text-woot-500 dark:text-woot-300 hover:text-woot-700 dark:hover:text-woot-500"
@click="onClickApply"
>
{{ $t('DATE_PICKER.APPLY_BUTTON') }}
</button>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { computed } from 'vue';
import { format, getMonth, setMonth, startOfMonth } from 'date-fns';
import {
yearName,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH, YEAR } = CALENDAR_PERIODS;
const months = Array.from({ length: 12 }, (_, index) =>
format(setMonth(startOfMonth(new Date()), index), 'MMM')
);
const activeMonthIndex = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getMonth(date);
});
const emit = defineEmits(['select-month', 'prev', 'next', 'set-view']);
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const selectMonth = index => {
emit('select-month', index);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="YEAR"
:calendar-type="calendarType"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate,
MONTH
)
"
@set-view="setViewMode"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid w-full grid-cols-3 gap-x-3 gap-y-2 auto-rows-[61px]">
<button
v-for="(month, index) in months"
:key="index"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[92px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:bg-woot-700':
index === activeMonthIndex,
}"
@click="selectMonth(index)"
>
{{ month }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,172 @@
<script setup>
import {
monthName,
yearName,
getWeeksForMonth,
isToday,
dayIsInRange,
isCurrentMonth,
isLastDayOfMonth,
isHoveringDayInRange,
isHoveringNextDayInRange,
CALENDAR_TYPES,
CALENDAR_PERIODS,
} from '../helpers/DatePickerHelper';
import CalendarWeekLabel from './CalendarWeekLabel.vue';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
currentDate: Date,
startCurrentDate: Date,
endCurrentDate: Date,
selectedStartDate: Date,
selectingEndDate: Boolean,
selectedEndDate: Date,
hoveredEndDate: Date,
});
const emit = defineEmits([
'update-hovered-end-date',
'select-date',
'prev',
'next',
'set-view',
]);
const { START_CALENDAR } = CALENDAR_TYPES;
const { MONTH } = CALENDAR_PERIODS;
const emitHoveredEndDate = day => {
emit('update-hovered-end-date', day);
};
const emitSelectDate = day => {
emit('select-date', day);
};
const onClickPrev = () => {
emit('prev');
};
const onClickNext = () => {
emit('next');
};
const setViewMode = (type, mode) => {
emit('set-view', type, mode);
};
const weeks = calendarType => {
return getWeeksForMonth(
calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isSelectedStartOrEndDate = day => {
return (
dayIsInRange(day, props.selectedStartDate, props.selectedStartDate) ||
dayIsInRange(day, props.selectedEndDate, props.selectedEndDate)
);
};
const isInRange = day => {
return dayIsInRange(day, props.selectedStartDate, props.selectedEndDate);
};
const isInCurrentMonth = day => {
return isCurrentMonth(
day,
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
);
};
const isHoveringInRange = day => {
return isHoveringDayInRange(
day,
props.selectedStartDate,
props.selectingEndDate,
props.hoveredEndDate
);
};
const isNextDayInRange = day => {
return isHoveringNextDayInRange(
day,
props.selectedStartDate,
props.selectedEndDate,
props.hoveredEndDate
);
};
const dayClasses = day => ({
'text-slate-500 dark:text-slate-400 pointer-events-none':
!isInCurrentMonth(day),
'text-slate-800 dark:text-slate-50 hover:text-slate-800 dark:hover:text-white hover:bg-woot-100 dark:hover:bg-woot-700':
isInCurrentMonth(day),
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white':
isSelectedStartOrEndDate(day) && isInCurrentMonth(day),
'bg-woot-50 dark:bg-woot-800':
(isInRange(day) || isHoveringInRange(day)) &&
!isSelectedStartOrEndDate(day) &&
isInCurrentMonth(day),
'outline outline-1 outline-woot-200 -outline-offset-1 dark:outline-woot-700 text-woot-600 dark:text-woot-400':
isToday(props.currentDate, day) && !isSelectedStartOrEndDate(day),
});
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:view-mode="MONTH"
:calendar-type="calendarType"
:first-button-label="
monthName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
:button-label="
yearName(
calendarType === START_CALENDAR ? startCurrentDate : endCurrentDate
)
"
@prev="onClickPrev"
@next="onClickNext"
@set-view="setViewMode"
/>
<CalendarWeekLabel />
<div
v-for="week in weeks(calendarType)"
:key="week[0].getTime()"
class="grid max-w-md grid-cols-7 gap-2 mx-auto overflow-hidden rounded-lg"
>
<div
v-for="day in week"
:key="day.getTime()"
class="flex relative items-center justify-center w-9 h-8 py-1.5 px-2 font-medium text-sm rounded-lg cursor-pointer"
:class="dayClasses(day)"
@mouseenter="emitHoveredEndDate(day)"
@mouseleave="emitHoveredEndDate(null)"
@click="emitSelectDate(day)"
>
{{ day.getDate() }}
<span
v-if="
(isInRange(day) || isHoveringInRange(day)) &&
isNextDayInRange(day) &&
!isLastDayOfMonth(day) &&
isInCurrentMonth(day)
"
class="absolute bottom-0 w-6 h-8 ltr:-right-4 rtl:-left-4 bg-woot-50 dark:bg-woot-800 -z-10"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,15 @@
<script setup>
import { calendarWeeks } from '../helpers/DatePickerHelper';
</script>
<template>
<div class="max-w-md mx-auto grid grid-cols-7 gap-2">
<div
v-for="day in calendarWeeks"
:key="day.id"
class="flex items-center justify-center font-medium text-sm w-9 h-7 py-1.5 px-2"
>
{{ day.label }}
</div>
</div>
</template>
@@ -0,0 +1,86 @@
<script setup>
import { ref, computed } from 'vue';
import { getYear, addYears, subYears } from 'date-fns';
import { CALENDAR_TYPES } from '../helpers/DatePickerHelper';
import CalendarAction from './CalendarAction.vue';
const props = defineProps({
calendarType: {
type: String,
default: 'start',
},
startCurrentDate: Date,
endCurrentDate: Date,
});
const { START_CALENDAR } = CALENDAR_TYPES;
const calculateStartYear = date => {
const year = getYear(date);
return year - (year % 10); // Align with the beginning of a decade
};
const startYear = ref(
calculateStartYear(
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate
)
);
const years = computed(() =>
Array.from({ length: 10 }, (_, i) => startYear.value + i)
);
const firstYear = computed(() => years.value[0]);
const lastYear = computed(() => years.value[years.value.length - 1]);
const activeYear = computed(() => {
const date =
props.calendarType === START_CALENDAR
? props.startCurrentDate
: props.endCurrentDate;
return getYear(date);
});
const onClickPrev = () => {
startYear.value = subYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const onClickNext = () => {
startYear.value = addYears(new Date(startYear.value, 0, 1), 10).getFullYear();
};
const emit = defineEmits(['select-year']);
const selectYear = year => {
emit('select-year', year);
};
</script>
<template>
<div class="flex flex-col w-full gap-2 max-h-[312px]">
<CalendarAction
:calendar-type="calendarType"
:button-label="`${firstYear} - ${lastYear}`"
@prev="onClickPrev"
@next="onClickNext"
/>
<div class="grid grid-cols-2 gap-x-3 gap-y-2 w-full auto-rows-[47px]">
<button
v-for="year in years"
:key="year"
class="p-2 text-sm font-medium text-center text-slate-800 dark:text-slate-50 w-[144px] h-10 rounded-lg py-2.5 px-2 hover:bg-slate-75 dark:hover:bg-slate-700"
:class="{
'bg-woot-600 dark:bg-woot-600 text-white dark:text-white hover:bg-woot-500 dark:hover:bg-woot-700':
year === activeYear,
}"
@click="selectYear(year)"
>
{{ year }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup>
import { computed } from 'vue';
import { dateRanges } from '../helpers/DatePickerHelper';
import { format, isSameYear, isValid } from 'date-fns';
const props = defineProps({
selectedStartDate: Date,
selectedEndDate: Date,
selectedRange: {
type: String,
default: '',
},
});
const formatDateRange = computed(() => {
const startDate = props.selectedStartDate;
const endDate = props.selectedEndDate;
if (!isValid(startDate) || !isValid(endDate)) {
return 'Select a date range';
}
const formatString = isSameYear(startDate, endDate)
? 'MMM d' // Same year: "Apr 1"
: 'MMM d yyyy'; // Different years: "Apr 1 2025"
if (isSameYear(startDate, new Date()) && isSameYear(endDate, new Date())) {
// Both dates are in the current year
return `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}`;
}
// At least one date is not in the current year
return `${format(startDate, formatString)} - ${format(
endDate,
formatString
)}`;
});
const activeDateRange = computed(
() => dateRanges.find(range => range.value === props.selectedRange).label
);
const emit = defineEmits(['open']);
const openDatePicker = () => {
emit('open');
};
</script>
<template>
<button
class="inline-flex relative items-center rounded-lg gap-2 py-1.5 px-3 h-8 bg-slate-50 dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800 active:bg-slate-75 dark:active:bg-slate-800"
@click="openDatePicker"
>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="calendar"
size="16"
/>
<span class="text-sm font-medium text-slate-800 dark:text-slate-50">
{{ $t(activeDateRange) }}
</span>
<span class="text-sm font-medium text-slate-600 dark:text-slate-200">
{{ formatDateRange }}
</span>
<fluent-icon
class="text-slate-800 dark:text-slate-50"
icon="chevron-down"
size="14"
/>
</button>
</template>
@@ -0,0 +1,219 @@
import {
startOfDay,
subDays,
endOfDay,
subMonths,
addMonths,
subYears,
addYears,
startOfMonth,
isSameMonth,
format,
startOfWeek,
addDays,
eachDayOfInterval,
endOfMonth,
isSameDay,
isWithinInterval,
} from 'date-fns';
// Constants for calendar and date ranges
export const calendarWeeks = [
{ id: 1, label: 'M' },
{ id: 2, label: 'T' },
{ id: 3, label: 'W' },
{ id: 4, label: 'T' },
{ id: 5, label: 'F' },
{ id: 6, label: 'S' },
{ id: 7, label: 'S' },
];
export const dateRanges = [
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_7_DAYS', value: 'last7days' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_30_DAYS', value: 'last30days' },
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
value: 'last3months',
},
{
label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
value: 'last6months',
},
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.LAST_YEAR', value: 'lastYear' },
{ label: 'DATE_PICKER.DATE_RANGE_OPTIONS.CUSTOM_RANGE', value: 'custom' },
];
export const DATE_RANGE_TYPES = {
LAST_7_DAYS: 'last7days',
LAST_30_DAYS: 'last30days',
LAST_3_MONTHS: 'last3months',
LAST_6_MONTHS: 'last6months',
LAST_YEAR: 'lastYear',
CUSTOM_RANGE: 'custom',
};
export const CALENDAR_TYPES = {
START_CALENDAR: 'start',
END_CALENDAR: 'end',
};
export const CALENDAR_PERIODS = {
WEEK: 'week',
MONTH: 'month',
YEAR: 'year',
};
// Utility functions for date operations
export const monthName = currentDate => format(currentDate, 'MMMM');
export const yearName = currentDate => format(currentDate, 'yyyy');
export const getIntlDateFormatForLocale = () => {
const year = 2222;
const month = 12;
const day = 15;
const date = new Date(year, month - 1, day);
const formattedDate = new Intl.DateTimeFormat(navigator.language).format(
date
);
return formattedDate
.replace(`${year}`, 'yyyy')
.replace(`${month}`, 'MM')
.replace(`${day}`, 'dd');
};
// Utility functions for calendar operations
export const chunk = (array, size) =>
Array.from({ length: Math.ceil(array.length / size) }, (_, index) =>
array.slice(index * size, index * size + size)
);
export const getWeeksForMonth = (date, weekStartsOn = 1) => {
const startOfTheMonth = startOfMonth(date);
const startOfTheFirstWeek = startOfWeek(startOfTheMonth, { weekStartsOn });
const endOfTheLastWeek = addDays(startOfTheFirstWeek, 41); // Covering six weeks to fill the calendar
return chunk(
eachDayOfInterval({ start: startOfTheFirstWeek, end: endOfTheLastWeek }),
7
);
};
export const moveCalendarDate = (
calendar,
startCurrentDate,
endCurrentDate,
direction,
period
) => {
const adjustFunctions = {
month: { prev: subMonths, next: addMonths },
year: { prev: subYears, next: addYears },
};
const adjust = adjustFunctions[period][direction];
if (calendar === 'start') {
const newStart = adjust(startCurrentDate, 1);
return { start: newStart, end: endCurrentDate };
}
const newEnd = adjust(endCurrentDate, 1);
return { start: startCurrentDate, end: newEnd };
};
// Date comparison functions
export const isToday = (currentDate, date) =>
date.getDate() === currentDate.getDate() &&
date.getMonth() === currentDate.getMonth() &&
date.getFullYear() === currentDate.getFullYear();
export const isCurrentMonth = (day, referenceDate) =>
isSameMonth(day, referenceDate);
export const isLastDayOfMonth = day => {
const lastDay = endOfMonth(day);
return day.getDate() === lastDay.getDate();
};
export const dayIsInRange = (date, startDate, endDate) => {
if (!startDate || !endDate) {
return false;
}
// Normalize dates to ignore time differences
let startOfDayStart = startOfDay(startDate);
let startOfDayEnd = startOfDay(endDate);
// Swap if start is greater than end
if (startOfDayStart > startOfDayEnd) {
[startOfDayStart, startOfDayEnd] = [startOfDayEnd, startOfDayStart];
}
// Check if the date is within the interval or is the same as the start date
return (
isSameDay(date, startOfDayStart) ||
isWithinInterval(date, {
start: startOfDayStart,
end: startOfDayEnd,
})
);
};
// Handling hovering states in date range pickers
export const isHoveringDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (endDate && hoveredEndDate && startDate <= hoveredEndDate) {
// Ensure the start date is not after the hovered end date
return isWithinInterval(day, { start: startDate, end: hoveredEndDate });
}
return false;
};
export const isHoveringNextDayInRange = (
day,
startDate,
endDate,
hoveredEndDate
) => {
if (startDate && !endDate && hoveredEndDate) {
// If a start date is selected, and we're hovering (but haven't clicked an end date yet)
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: hoveredEndDate });
}
if (startDate && endDate) {
// Normal range checking between selected start and end dates
const nextDay = addDays(day, 1);
return isWithinInterval(nextDay, { start: startDate, end: endDate });
}
return false;
};
// Helper func to determine active date ranges based on user selection
export const getActiveDateRange = (range, currentDate) => {
const ranges = {
last7days: () => ({
start: startOfDay(subDays(currentDate, 6)),
end: endOfDay(currentDate),
}),
last30days: () => ({
start: startOfDay(subDays(currentDate, 29)),
end: endOfDay(currentDate),
}),
last3months: () => ({
start: startOfDay(subMonths(currentDate, 3)),
end: endOfDay(currentDate),
}),
last6months: () => ({
start: startOfDay(subMonths(currentDate, 6)),
end: endOfDay(currentDate),
}),
lastYear: () => ({
start: startOfDay(subMonths(currentDate, 12)),
end: endOfDay(currentDate),
}),
custom: () => ({ start: currentDate, end: currentDate }),
};
return (
ranges[range] || (() => ({ start: currentDate, end: currentDate }))
)();
};
@@ -0,0 +1,309 @@
import {
monthName,
yearName,
getIntlDateFormatForLocale,
getWeeksForMonth,
isToday,
isCurrentMonth,
isLastDayOfMonth,
dayIsInRange,
getActiveDateRange,
isHoveringDayInRange,
isHoveringNextDayInRange,
moveCalendarDate,
chunk,
} from '../DatePickerHelper';
describe('Date formatting functions', () => {
const testDate = new Date(2020, 4, 15); // May 15, 2020
beforeEach(() => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-US');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('returns the correct month name from a date', () => {
expect(monthName(testDate)).toBe('May');
});
it('returns the correct year from a date', () => {
expect(yearName(testDate)).toBe('2020');
});
it('returns the correct date format for the current locale en-US', () => {
const expected = 'MM/dd/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
it('returns the correct date format for the current locale en-IN', () => {
jest.spyOn(navigator, 'language', 'get').mockReturnValue('en-IN');
const expected = 'dd/MM/yyyy';
expect(getIntlDateFormatForLocale()).toBe(expected);
});
});
describe('chunk', () => {
const array = [1, 2, 3, 4, 5, 6, 7, 8, 9];
it('correctly chunks an array into smaller arrays of given size', () => {
const expected = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
expect(chunk(array, 3)).toEqual(expected);
});
it('handles arrays that do not divide evenly by the chunk size', () => {
const expected = [[1, 2], [3, 4], [5, 6], [7, 8], [9]];
expect(chunk(array, 2)).toEqual(expected);
});
});
describe('getWeeksForMonth', () => {
it('returns the correct weeks array for a month starting on Monday', () => {
const date = new Date(2020, 3, 1); // April 2020
const weeks = getWeeksForMonth(date, 1);
expect(weeks.length).toBe(6);
expect(weeks[0][0]).toEqual(new Date(2020, 2, 30)); // Check if first day of the first week is correct
});
});
describe('moveCalendarDate', () => {
it('handles the year transition when moving the start date backward by one month from January', () => {
const startDate = new Date(2021, 0, 1);
const endDate = new Date(2021, 0, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'prev',
'month'
);
const expectedStartDate = new Date(2020, 11, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the start date forward by one month from January', () => {
const startDate = new Date(2020, 0, 1);
const endDate = new Date(2020, 1, 31);
const result = moveCalendarDate(
'start',
startDate,
endDate,
'next',
'month'
);
const expectedStartDate = new Date(2020, 1, 1);
expect(result.start.toISOString()).toEqual(expectedStartDate.toISOString());
expect(result.end.toISOString()).toEqual(endDate.toISOString());
});
it('handles the year transition when moving the end date forward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'next', 'month');
const expectedEndDate = new Date(2022, 0, 31);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
it('handles the year transition when moving the end date backward by one month from December', () => {
const startDate = new Date(2021, 11, 1);
const endDate = new Date(2021, 11, 31);
const result = moveCalendarDate('end', startDate, endDate, 'prev', 'month');
const expectedEndDate = new Date(2021, 10, 30);
expect(result.start).toEqual(startDate);
expect(result.end.toISOString()).toEqual(expectedEndDate.toISOString());
});
});
describe('isToday', () => {
it('returns true if the dates are the same', () => {
const today = new Date();
const alsoToday = new Date(today);
expect(isToday(today, alsoToday)).toBeTruthy();
});
it('returns false if the dates are not the same', () => {
const today = new Date();
const notToday = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() - 1
);
expect(isToday(today, notToday)).toBeFalsy();
});
});
describe('isCurrentMonth', () => {
const referenceDate = new Date(2020, 6, 15); // July 15, 2020
it('returns true if the day is in the same month as the reference date', () => {
const testDay = new Date(2020, 6, 1);
expect(isCurrentMonth(testDay, referenceDate)).toBeTruthy();
});
it('returns false if the day is not in the same month as the reference date', () => {
const testDay = new Date(2020, 5, 30);
expect(isCurrentMonth(testDay, referenceDate)).toBeFalsy();
});
});
describe('isLastDayOfMonth', () => {
it('returns true if the day is the last day of the month', () => {
const testDay = new Date(2020, 6, 31); // July 31, 2020
expect(isLastDayOfMonth(testDay)).toBeTruthy();
});
it('returns false if the day is not the last day of the month', () => {
const testDay = new Date(2020, 6, 30); // July 30, 2020
expect(isLastDayOfMonth(testDay)).toBeFalsy();
});
});
describe('dayIsInRange', () => {
it('returns true if the date is within the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 15);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns true if the date is the same as the start date', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 10);
expect(dayIsInRange(testDate, start, end)).toBeTruthy();
});
it('returns false if the date is outside the range', () => {
const start = new Date(2020, 1, 10);
const end = new Date(2020, 1, 20);
const testDate = new Date(2020, 1, 9);
expect(dayIsInRange(testDate, start, end)).toBeFalsy();
});
});
describe('isHoveringDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const endDate = new Date(2020, 6, 20);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 12);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the day is outside the hovered date range', () => {
const testDay = new Date(2020, 6, 16);
expect(
isHoveringDayInRange(testDay, startDate, endDate, hoveredEndDate)
).toBeFalsy();
});
});
describe('isHoveringNextDayInRange', () => {
const startDate = new Date(2020, 6, 10);
const hoveredEndDate = new Date(2020, 6, 15);
it('returns true if the next day after the given day is within the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 14);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeTruthy();
});
it('returns false if the next day is outside the start and hovered end dates', () => {
const testDay = new Date(2020, 6, 15);
expect(
isHoveringNextDayInRange(testDay, startDate, null, hoveredEndDate)
).toBeFalsy();
});
});
describe('getActiveDateRange', () => {
const currentDate = new Date(2020, 5, 15, 12, 0); // May 15, 2020, at noon
beforeAll(() => {
// Mocking the current date to ensure consistency in tests
jest.useFakeTimers().setSystemTime(currentDate.getTime());
});
afterAll(() => {
jest.useRealTimers();
});
it('returns the correct range for "last7days"', () => {
const range = getActiveDateRange('last7days', new Date());
const expectedStart = new Date(2020, 5, 9);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last30days"', () => {
const range = getActiveDateRange('last30days', new Date());
const expectedStart = new Date(2020, 4, 17);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last3months"', () => {
const range = getActiveDateRange('last3months', new Date());
const expectedStart = new Date(2020, 2, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "last6months"', () => {
const range = getActiveDateRange('last6months', new Date());
const expectedStart = new Date(2019, 11, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "lastYear"', () => {
const range = getActiveDateRange('lastYear', new Date());
const expectedStart = new Date(2019, 5, 15);
expectedStart.setHours(0, 0, 0, 0);
const expectedEnd = new Date();
expectedEnd.setHours(23, 59, 59, 999);
expect(range.start).toEqual(expectedStart);
expect(range.end).toEqual(expectedEnd);
});
it('returns the correct range for "custom date range"', () => {
const range = getActiveDateRange('custom', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
it('handles an unknown range label gracefully', () => {
const range = getActiveDateRange('unknown', new Date());
expect(range.start).toEqual(new Date(currentDate));
expect(range.end).toEqual(new Date(currentDate));
});
});
@@ -1,9 +1,9 @@
<script setup>
import { ref, computed } from 'vue';
import { picoSearch } from '@scmmishra/pico-search';
import FilterListItemButton from './FilterListItemButton.vue';
import FilterDropdownSearch from './FilterDropdownSearch.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue';
import ListItemButton from './DropdownListItemButton.vue';
import DropdownSearch from './DropdownSearch.vue';
import DropdownEmptyState from './DropdownEmptyState.vue';
const props = defineProps({
listItems: {
@@ -19,7 +19,7 @@ const props = defineProps({
default: '',
},
activeFilterId: {
type: Number,
type: [String, Number],
default: null,
},
showClearFilter: {
@@ -28,10 +28,13 @@ const props = defineProps({
},
});
const emits = defineEmits(['on-search']);
const searchTerm = ref('');
const onSearch = value => {
searchTerm.value = value;
emits('on-search', value);
};
const filteredListItems = computed(() => {
@@ -54,8 +57,8 @@ const isFilterActive = id => {
@click.stop
>
<slot name="search">
<filter-dropdown-search
v-if="enableSearch && listItems.length"
<dropdown-search
v-if="enableSearch"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@@ -64,11 +67,11 @@ const isFilterActive = id => {
/>
</slot>
<slot name="listItem">
<filter-dropdown-empty-state
<dropdown-empty-state
v-if="isDropdownListEmpty"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/>
<filter-list-item-button
<list-item-button
v-for="item in filteredListItems"
:key="item.id"
:is-active="isFilterActive(item.id)"
@@ -0,0 +1,24 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div class="relative group w-[inherit] whitespace-normal z-20">
<fluent-icon
icon="info"
size="14"
class="mt-0.5 text-slate-600 absolute dark:text-slate-400"
/>
<div
class="bg-white dark:bg-slate-900 w-fit ltr:left-4 rtl:right-4 top-0 border p-2.5 group-hover:flex items-center hidden absolute border-slate-75 dark:border-slate-800 rounded-lg shadow-md"
>
<p class="text-slate-800 dark:text-slate-75 mb-0 text-xs">
{{ message }}
</p>
</div>
</div>
</template>
@@ -18,13 +18,11 @@
<script>
import { Chrome } from 'vue-color';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Chrome,
},
mixins: [clickaway],
props: {
value: {
type: String,
@@ -33,7 +33,6 @@
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
import { mixin as clickaway } from 'vue-clickaway';
import adminMixin from 'dashboard/mixins/isAdmin';
export default {
@@ -42,7 +41,7 @@ export default {
LabelDropdown,
},
mixins: [clickaway, adminMixin, keyboardEventListenerMixins],
mixins: [adminMixin, keyboardEventListenerMixins],
props: {
allLabels: {
@@ -5,6 +5,7 @@
</template>
<script>
import getUuid from 'widget/helpers/uuid';
import 'video.js/dist/video-js.css';
import 'videojs-record/dist/css/videojs.record.css';
@@ -28,9 +29,25 @@ import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { convertWavToMp3 } from './utils/mp3ConversionUtils';
WaveSurfer.microphone = MicrophonePlugin;
const RECORDER_CONFIG = {
[AUDIO_FORMATS.WAV]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.MP3]: {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
},
[AUDIO_FORMATS.OGG]: {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
},
};
export default {
name: 'WootAudioRecorder',
mixins: [alertMixin],
@@ -39,6 +56,10 @@ export default {
type: String,
default: AUDIO_FORMATS.WAV,
},
isAWhatsAppChannel: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -88,14 +109,7 @@ export default {
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
}),
...RECORDER_CONFIG[this.audioRecordFormat],
},
},
},
@@ -133,7 +147,11 @@ export default {
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
if (
[AUDIO_FORMATS.WAV, AUDIO_FORMATS.MP3].includes(
this.audioRecordFormat
)
) {
this.player.record().engine.audioType = 'audio/wav';
}
}
@@ -145,12 +163,16 @@ export default {
stopRecord() {
this.fireStateRecorderChanged('stopped');
},
finishRecord() {
const file = new File(
[this.player.recordedData],
this.player.recordedData.name,
{ type: this.player.recordedData.type }
);
async finishRecord() {
let recordedContent = this.player.recordedData;
let fileName = this.player.recordedData.name;
let type = this.player.recordedData.type;
if (this.audioRecordFormat === AUDIO_FORMATS.MP3) {
recordedContent = await convertWavToMp3(this.player.recordedData);
fileName = `${getUuid()}.mp3`;
type = AUDIO_FORMATS.MP3;
}
const file = new File([recordedContent], fileName, { type });
this.fireRecorderBlob(file);
},
progressRecord() {
@@ -231,11 +253,13 @@ export default {
@apply bg-transparent max-h-60 min-h-[3rem] pt-4 px-0 pb-0 resize-none;
}
}
// Added to override the default text and bg style to support dark and light mode.
.video-js .vjs-control-bar,
.vjs-record.video-js .vjs-control.vjs-record-indicator:before {
@apply text-slate-600 dark:text-slate-200 bg-transparent dark:bg-transparent;
}
// Added to fix div overlays the screen and takes over the button clicks
// https://github.com/collab-project/videojs-record/issues/688
// https://github.com/collab-project/videojs-record/pull/709
@@ -0,0 +1,53 @@
import lamejs from 'lamejs';
/**
* Encodes a mono channel audio stream to MP3 format.
* @param {number} channels - Number of audio channels.
* @param {number} sampleRate - Sample rate in Hz.
* @param {Int16Array} samples - Audio samples to be encoded.
* @returns {Blob} - The MP3 encoded audio as a Blob.
*/
export const encodeToMP3 = (channels, sampleRate, samples) => {
const outputBuffer = [];
const encoder = new lamejs.Mp3Encoder(channels, sampleRate, 128);
const maxSamplesPerFrame = 1152;
for (let offset = 0; offset < samples.length; offset += maxSamplesPerFrame) {
const sliceEnd = Math.min(offset + maxSamplesPerFrame, samples.length);
const sampleSlice = samples.subarray(offset, sliceEnd);
const mp3Buffer = encoder.encodeBuffer(sampleSlice);
if (mp3Buffer.length > 0) {
outputBuffer.push(new Int8Array(mp3Buffer));
}
}
const remainingData = encoder.flush();
if (remainingData.length > 0) {
outputBuffer.push(new Int8Array(remainingData));
}
return new Blob(outputBuffer, { type: 'audio/mp3' });
};
/**
* Converts a WAV audio Blob to an MP3 format Blob.
* @param {Blob} blob - The audio data in WAV format as a Blob.
* @returns {Promise<Blob>} - A Blob containing the MP3 encoded audio.
*/
export const convertWavToMp3 = async blob => {
try {
const audioBuffer = await blob.arrayBuffer();
const wavHeader = lamejs.WavHeader.readHeader(new DataView(audioBuffer));
const samples = new Int16Array(
audioBuffer,
wavHeader.dataOffset,
wavHeader.dataLen / 2
);
return encodeToMP3(wavHeader.channels, wavHeader.sampleRate, samples);
} catch (error) {
// eslint-disable-next-line
console.log('Failed to convert WAV to MP3:', error);
throw new Error('Conversion from WAV to MP3 failed.');
}
};
@@ -45,7 +45,6 @@
<script>
import wootConstants from 'dashboard/constants/globals';
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import FilterItem from './FilterItem.vue';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -53,7 +52,7 @@ export default {
components: {
FilterItem,
},
mixins: [clickaway, uiSettingsMixin],
mixins: [uiSettingsMixin],
data() {
return {
showActionsDropdown: false,
@@ -103,6 +103,7 @@
:status="chat.status"
:inbox-id="inbox.id"
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@@ -71,6 +71,7 @@
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<linear :conversation-id="currentChat.id" />
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -89,6 +90,8 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
export default {
components: {
@@ -97,6 +100,7 @@ export default {
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
@@ -121,6 +125,9 @@ export default {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
@@ -178,6 +185,17 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
methods: {
@@ -37,7 +37,6 @@
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal.vue';
import ResolveAction from '../../buttons/ResolveAction.vue';
@@ -52,7 +51,7 @@ export default {
EmailTranscriptModal,
ResolveAction,
},
mixins: [alertMixin, clickaway],
mixins: [alertMixin],
data() {
return {
showEmailActionsModal: false,
@@ -153,7 +153,6 @@
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
@@ -218,7 +217,6 @@ export default {
ArticleSearchPopover,
},
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
@@ -503,11 +501,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (
this.isAWhatsAppChannel ||
this.isAPIInbox ||
this.isATelegramChannel
) {
if (this.isAWhatsAppChannel || this.isATelegramChannel) {
return AUDIO_FORMATS.MP3;
}
if (this.isAPIInbox) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -1255,6 +1252,7 @@ export default {
}
}
}
.send-button {
@apply mb-0;
}
@@ -1279,6 +1277,7 @@ export default {
.emoji-dialog--rtl {
@apply left-[unset] -right-80;
&::before {
transform: rotate(90deg);
filter: drop-shadow(0px 4px 4px rgba(0, 0, 0, 0.08));
@@ -9,7 +9,7 @@
<woot-input
v-model.trim="$v.toEmailsVal.$model"
type="text"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
:class="{ error: $v.toEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur"
@@ -25,7 +25,7 @@
<div class="rounded-none flex-1 min-w-0 m-0 whitespace-nowrap">
<woot-input
v-model.trim="$v.ccEmailsVal.$model"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
type="text"
:class="{ error: $v.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@@ -54,7 +54,7 @@
<woot-input
v-model.trim="$v.bccEmailsVal.$model"
type="text"
class="[&>input]:mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
class="[&>input]:!mb-0 [&>input]:border-transparent [&>input]:h-8 [&>input]:text-sm [&>input]:!border-0 [&>input]:border-none"
:class="{ error: $v.bccEmailsVal.$error }"
:placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')
@@ -7,8 +7,10 @@
'bg-woot-600 text-woot-50': messageType === MESSAGE_TYPE.OUTGOING,
'-mx-2': !parentHasAttachments,
}"
@click="scrollToMessage"
>
<message-preview
class="cursor-pointer"
:message="message"
:show-message-type="false"
:default-empty-message="$t('CONVERSATION.REPLY_MESSAGE_NOT_FOUND')"
@@ -19,6 +21,7 @@
<script>
import MessagePreview from 'dashboard/components/widgets/conversation/MessagePreview.vue';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
name: 'ReplyTo',
@@ -42,5 +45,10 @@ export default {
data() {
return { MESSAGE_TYPE };
},
methods: {
scrollToMessage() {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id });
},
},
};
</script>
@@ -180,7 +180,6 @@
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import timeMixin from 'dashboard/mixins/time';
@@ -200,7 +199,7 @@ export default {
components: {
Thumbnail,
},
mixins: [keyboardEventListenerMixins, clickaway, timeMixin],
mixins: [keyboardEventListenerMixins, timeMixin],
props: {
show: {
type: Boolean,
@@ -49,9 +49,8 @@
</template>
<script>
import { evaluateSLAStatus } from '../helpers/SLAHelper';
import { evaluateSLAStatus } from '@chatwoot/utils';
import SLAPopoverCard from './SLAPopoverCard.vue';
import { mixin as clickaway } from 'vue-clickaway';
const REFRESH_INTERVAL = 60000;
@@ -59,7 +58,6 @@ export default {
components: {
SLAPopoverCard,
},
mixins: [clickaway],
props: {
chat: {
type: Object,
@@ -139,7 +137,10 @@ export default {
}, REFRESH_INTERVAL);
},
updateSlaStatus() {
this.slaStatus = evaluateSLAStatus(this.appliedSLA, this.chat);
this.slaStatus = evaluateSLAStatus({
appliedSla: this.appliedSLA,
chat: this.chat,
});
},
openSlaPopover() {
if (!this.showExtendedInfo) return;
@@ -16,7 +16,7 @@
/>
</template>
<menu-item
v-if="show(snoozeOption.key)"
v-if="showSnooze"
:option="snoozeOption"
variant="icon"
@click="snoozeConversation()"
@@ -86,6 +86,10 @@ export default {
},
mixins: [agentMixin],
props: {
chatId: {
type: Number,
default: null,
},
status: {
type: String,
default: '',
@@ -205,6 +209,10 @@ export default {
...this.filteredAgentOnAvailability,
];
},
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
},
mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -213,7 +221,8 @@ export default {
toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil);
},
snoozeConversation() {
async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
@@ -101,14 +101,12 @@
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
Thumbnail,
Spinner,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
@@ -95,20 +95,40 @@
<div v-if="allConversationsSelected" class="bulk-action__alert">
{{ $t('BULK_ACTION.ALL_CONVERSATIONS_SELECTED_ALERT') }}
</div>
<woot-modal
:show.sync="showCustomTimeSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="customSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
import LabelActions from './LabelActions.vue';
import TeamActions from './TeamActions.vue';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
export default {
components: {
AgentSelector,
UpdateActions,
LabelActions,
TeamActions,
CustomSnoozeModal,
},
props: {
conversations: {
@@ -143,17 +163,56 @@ export default {
showLabelActions: false,
showTeamsList: false,
popoverPositions: {},
showCustomTimeSnoozeModal: false,
};
},
mounted() {
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
destroyed() {
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
methods: {
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomTimeSnoozeModal = true;
} else {
this.updateConversations('snoozed', findSnoozeTime(snoozeType) || null);
}
},
onCmdReopenConversation() {
this.updateConversations('open', null);
},
onCmdResolveConversation() {
this.updateConversations('resolved', null);
},
customSnoozeTime(customSnoozedTime) {
this.showCustomTimeSnoozeModal = false;
if (customSnoozedTime) {
this.updateConversations('snoozed', getUnixTime(customSnoozedTime));
}
},
hideCustomSnoozeModal() {
this.showCustomTimeSnoozeModal = false;
},
selectAll(e) {
this.$emit('select-all-conversations', e.target.checked);
},
submit(agent) {
this.$emit('assign-agent', agent);
},
updateConversations(status) {
this.$emit('update-conversations', status);
updateConversations(status, snoozedUntil) {
this.$emit('update-conversations', status, snoozedUntil);
},
assignLabels(labels) {
this.$emit('assign-labels', labels);
@@ -74,11 +74,9 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',
@@ -59,10 +59,8 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { mapGetters } from 'vuex';
export default {
mixins: [clickaway],
data() {
return {
query: '',
@@ -1,12 +1,98 @@
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { ref } from 'vue';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
const { t } = useI18n();
const emits = defineEmits(['update', 'close']);
const props = defineProps({
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
});
const actions = ref([
{ icon: 'checkmark', key: 'resolved' },
{ icon: 'arrow-redo', key: 'open' },
{ icon: 'send-clock', key: 'snoozed' },
]);
const updateConversations = key => {
if (key === 'snoozed') {
// If the user clicks on the snooze option from the bulk action change status dropdown.
// Open the snooze option for bulk action in the cmd bar.
const ninja = document.querySelector('ninja-keys');
ninja?.open({ parent: 'bulk_action_snooze_conversation' });
} else {
emits('update', key);
}
};
const onClose = () => {
emits('close');
};
const showAction = key => {
const actionsMap = {
resolved: props.showResolve,
open: props.showReopen,
snoozed: props.showSnooze,
};
return actionsMap[key] || false;
};
const actionLabel = key => {
const labelsMap = {
resolved: t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: t('BULK_ACTION.UPDATE.SNOOZE_UNTIL'),
};
return labelsMap[key] || '';
};
</script>
<template>
<div v-on-clickaway="onClose" class="actions-container">
<div class="triangle">
<div
v-on-clickaway="onClose"
class="absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md"
>
<div
class="right-[var(--triangle-position)] block z-10 absolute text-left -top-3"
>
<svg height="12" viewBox="0 0 24 12" width="24">
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
<path
d="M20 12l-8-8-12 12"
fill-rule="evenodd"
stroke-width="1px"
class="fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50"
/>
</svg>
</div>
<div class="header flex items-center justify-between">
<span>{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}</span>
<div class="p-2.5 flex gap-1 items-center justify-between">
<span class="text-sm font-medium text-slate-600 dark:text-slate-100">
{{ $t('BULK_ACTION.UPDATE.CHANGE_STATUS') }}
</span>
<woot-button
size="tiny"
variant="clear"
@@ -15,8 +101,8 @@
@click="onClose"
/>
</div>
<div class="container">
<woot-dropdown-menu>
<div class="px-2.5 pt-0 pb-2.5">
<woot-dropdown-menu class="m-0 list-none">
<template v-for="action in actions">
<woot-dropdown-item v-if="showAction(action.key)" :key="action.key">
<woot-button
@@ -34,116 +120,3 @@
</div>
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
},
mixins: [clickaway],
props: {
selectedInboxes: {
type: Array,
default: () => [],
},
conversationCount: {
type: Number,
default: 0,
},
showResolve: {
type: Boolean,
default: true,
},
showReopen: {
type: Boolean,
default: true,
},
showSnooze: {
type: Boolean,
default: true,
},
},
data() {
return {
query: '',
selectedAction: null,
actions: [
{
icon: 'checkmark',
key: 'resolved',
},
{
icon: 'arrow-redo',
key: 'open',
},
{
icon: 'send-clock',
key: 'snoozed',
},
],
};
},
methods: {
updateConversations(key) {
this.$emit('update', key);
},
goBack() {
this.selectedAgent = null;
},
onClose() {
this.$emit('close');
},
showAction(key) {
const actionsMap = {
resolved: this.showResolve,
open: this.showReopen,
snoozed: this.showSnooze,
};
return actionsMap[key] || false;
},
actionLabel(key) {
const labelsMap = {
resolved: this.$t('CONVERSATION.HEADER.RESOLVE_ACTION'),
open: this.$t('CONVERSATION.HEADER.REOPEN_ACTION'),
snoozed: this.$t('BULK_ACTION.UPDATE.SNOOZE_UNTIL_NEXT_REPLY'),
};
return labelsMap[key] || '';
},
},
};
</script>
<style scoped lang="scss">
.actions-container {
@apply absolute right-2 top-12 origin-top-right w-auto z-20 bg-white dark:bg-slate-800 rounded-lg border border-solid border-slate-50 dark:border-slate-700 shadow-md;
.header {
@apply p-2.5;
span {
@apply text-sm font-medium;
}
}
.container {
@apply px-2.5 pt-0 pb-2.5;
}
.triangle {
right: var(--triangle-position);
@apply block z-10 absolute text-left -top-3;
svg path {
@apply fill-white dark:fill-slate-800 stroke-slate-50 dark:stroke-slate-600/50;
}
}
}
ul {
@apply m-0 list-none;
}
</style>
@@ -1,117 +0,0 @@
const calculateThreshold = (timeOffset, threshold) => {
// Calculate the time left for the SLA to breach or the time since the SLA has missed
if (threshold === null) return null;
const currentTime = Math.floor(Date.now() / 1000);
return timeOffset + threshold - currentTime;
};
const findMostUrgentSLAStatus = SLAStatuses => {
// Sort the SLAs based on the threshold and return the most urgent SLA
SLAStatuses.sort(
(sla1, sla2) => Math.abs(sla1.threshold) - Math.abs(sla2.threshold)
);
return SLAStatuses[0];
};
const formatSLATime = seconds => {
const units = {
y: 31536000, // 60 * 60 * 24 * 365
mo: 2592000, // 60 * 60 * 24 * 30
d: 86400, // 60 * 60 * 24
h: 3600, // 60 * 60
m: 60,
};
if (seconds < 60) {
return '1m';
}
// we will only show two parts, two max granularity's, h-m, y-d, d-h, m, but no seconds
const parts = [];
Object.keys(units).forEach(unit => {
const value = Math.floor(seconds / units[unit]);
if (seconds < 60 && parts.length > 0) return;
if (parts.length === 2) return;
if (value > 0) {
parts.push(value + unit);
seconds -= value * units[unit];
}
});
return parts.join(' ');
};
const createSLAObject = (
type,
{
sla_first_response_time_threshold: frtThreshold,
sla_next_response_time_threshold: nrtThreshold,
sla_resolution_time_threshold: rtThreshold,
created_at: createdAt,
} = {},
{
first_reply_created_at: firstReplyCreatedAt,
waiting_since: waitingSince,
status,
} = {}
) => {
// Mapping of breach types to their logic
const SLATypes = {
FRT: {
threshold: calculateThreshold(createdAt, frtThreshold),
// Check FRT only if threshold is not null and first reply hasn't been made
condition:
frtThreshold !== null &&
(!firstReplyCreatedAt || firstReplyCreatedAt === 0),
},
NRT: {
threshold: calculateThreshold(waitingSince, nrtThreshold),
// Check NRT only if threshold is not null, first reply has been made and we are waiting since
condition:
nrtThreshold !== null && !!firstReplyCreatedAt && !!waitingSince,
},
RT: {
threshold: calculateThreshold(createdAt, rtThreshold),
// Check RT only if the conversation is open and threshold is not null
condition: status === 'open' && rtThreshold !== null,
},
};
const SLAStatus = SLATypes[type];
return SLAStatus ? { ...SLAStatus, type } : null;
};
const evaluateSLAConditions = (appliedSla, chat) => {
// Filter out the SLA based on conditions and update the object with the breach status(icon, isSlaMissed)
const SLATypes = ['FRT', 'NRT', 'RT'];
return SLATypes.map(type => createSLAObject(type, appliedSla, chat))
.filter(SLAStatus => SLAStatus && SLAStatus.condition)
.map(SLAStatus => ({
...SLAStatus,
icon: SLAStatus.threshold <= 0 ? 'flame' : 'alarm',
isSlaMissed: SLAStatus.threshold <= 0,
}));
};
export const evaluateSLAStatus = (appliedSla, chat) => {
if (!appliedSla || !chat)
return { type: '', threshold: '', icon: '', isSlaMissed: false };
// Filter out the SLA and create the object for each breach
const SLAStatuses = evaluateSLAConditions(appliedSla, chat);
// Return the most urgent SLA which is latest to breach or has missed
const mostUrgent = findMostUrgentSLAStatus(SLAStatuses);
return mostUrgent
? {
type: mostUrgent.type,
threshold: formatSLATime(
mostUrgent.threshold <= 0
? -mostUrgent.threshold
: mostUrgent.threshold
),
icon: mostUrgent.icon,
isSlaMissed: mostUrgent.isSlaMissed,
}
: { type: '', threshold: '', icon: '', isSlaMissed: false };
};
@@ -1,150 +0,0 @@
import { evaluateSLAStatus } from '../SLAHelper';
beforeEach(() => {
jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date('2024-01-01T00:00:00Z').getTime());
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('SLAHelper', () => {
describe('evaluateSLAStatus', () => {
it('returns an empty object when sla or chat is not present', () => {
expect(evaluateSLAStatus(null, null)).toEqual({
type: '',
threshold: '',
icon: '',
isSlaMissed: false,
});
});
// Case when FRT SLA is missed
it('correctly identifies a missed FRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066540,
};
const chatMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when FRT SLA is not missed
it('correctly identifies an FRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704066660,
};
const chatNotMissed = {
first_reply_created_at: 0,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'FRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when NRT SLA is missed
it('correctly identifies a missed NRT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704065940,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when NRT SLA is not missed
it('correctly identifies an NRT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065200 - 2000,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 1704066060,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'NRT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
// Case when RT SLA is missed
it('correctly identifies a missed RT SLA', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065340,
};
const chatMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'flame',
isSlaMissed: true,
});
});
// Case when RT SLA is not missed
it('correctly identifies an RT SLA not yet breached', () => {
const appliedSla = {
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 1200,
sla_resolution_time_threshold: 1800,
created_at: 1704065460,
};
const chatNotMissed = {
first_reply_created_at: 1704066200,
waiting_since: 0,
status: 'open',
};
expect(evaluateSLAStatus(appliedSla, chatNotMissed)).toEqual({
type: 'RT',
threshold: '1m',
icon: 'alarm',
isSlaMissed: false,
});
});
});
});
@@ -0,0 +1,48 @@
<script setup>
import FormButton from 'v3/components/Form/Button.vue';
const emits = defineEmits(['on-dismiss']);
const onDismiss = () => {
emits('on-dismiss');
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-ash-50 dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[270px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<div class="flex flex-row justify-between">
<img
class="w-8 h-8"
:src="`/dashboard/images/integrations/linear.png`"
alt="Linear-logo"
/>
<div class="flex items-center">
<woot-button
variant="clear"
icon="dismiss"
color-scheme="secondary"
@click="onDismiss"
/>
</div>
</div>
<span class="mt-3 font-medium text-ash-900">Connect to Linear</span>
<span class="mt-2 text-sm text-ash-800">
Create Linear issues from conversations, or link existing ones for
seamless tracking.
</span>
<form-button
type="submit"
size="large"
class="justify-center w-full mt-6"
variant="solid"
color-scheme="secondary"
>
Connect to Linear
</form-button>
</div>
</div>
</template>
@@ -0,0 +1,232 @@
<template>
<div @submit.prevent="onSubmit">
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<label :class="{ error: v$.teamId.$error }">
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
<select
v-model="formState.teamId"
:style="inputStyles"
@change="onChangeTeam"
>
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.teamId.$error" class="message">
{{ teamError }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
<select v-model="formState.assigneeId" :style="inputStyles">
<option v-for="item in assignees" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
<select v-model="formState.labelId" :style="inputStyles">
<option v-for="item in labels" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
<select v-model="formState.priority" :style="inputStyles">
<option v-for="item in priorities" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
<select v-model="formState.projectId" :style="inputStyles">
<option v-for="item in projects" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
<select v-model="formState.stateId" :style="inputStyles">
<option v-for="item in statuses" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR'));
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
statuses.value = response.data.states;
projects.value = response.data.projects;
} catch (error) {
useAlert(
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
}
};
const onChangeTeam = event => {
formState.teamId = event.target.value;
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
getTeamEntities();
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose();
} catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR'));
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
@@ -0,0 +1,79 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<create-issue
:account-id="accountId"
:conversation-id="conversationId"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<link-issue :conversation-id="conversationId" @close="onClose" />
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
});
const { t } = useI18n();
const selectedTabIndex = ref(0);
const emits = defineEmits(['close']);
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emits('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
@@ -0,0 +1,120 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
return props.issue.assignee
? {
name: props.issue.assignee?.name || '',
thumbnail: props.issue.assignee?.avatarUrl || '',
}
: null;
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlink-issue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-ash-50 dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<issue-header
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-ash-900">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-ash-800 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-ash-900">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-ash-800">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -0,0 +1,56 @@
<template>
<div class="flex flex-row justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-0.5">
<woot-button
variant="clear"
color-scheme="secondary"
class="h-[24px]"
@click="unlinkIssue"
>
<fluent-icon icon="unlink" size="12" type="outline" icon-lib="lucide" />
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div>
</div>
</template>
<script setup>
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => {
emit('unlink-issue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
@@ -0,0 +1,127 @@
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@click="onSelectIssue"
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking"
@click.prevent="linkIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const emits = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emits('close');
};
const onSearch = async value => {
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
}));
} catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR'));
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
} catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR'));
} finally {
isLinking.value = false;
}
};
</script>
@@ -0,0 +1,168 @@
<template>
<div class="relative" :class="{ group: linkedIssue || shouldShowPopup }">
<woot-button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
variant="clear"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }}
</span>
</woot-button>
<issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<CTAModal
v-if="shouldShowCTA"
class="absolute right-0 top-[40px]"
@on-dismiss="onDismissCTAModal"
/>
<woot-modal
:show.sync="shouldShowPopup"
:on-close="closePopup"
class="!items-start [&>div]:!top-12"
>
<create-or-link-issue
:conversation-id="conversationId"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import CTAModal from './CTAModal.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const currentAccountId = getters.getCurrentAccountId;
const uiSettings = getters.getUISettings;
const currentUserRole = getters.getCurrentRole;
const isAdmin = computed(() => {
return currentUserRole.value === 'administrator';
});
const isLinearFeatureEnabled = computed(() => {
return getters['accounts/isFeatureEnabledonAccount'].value(
currentAccountId.value,
FEATURE_FLAGS.LINEAR
);
});
const isLinearIntegrationEnabled = computed(() => {
return getters['integrations/getAppIntegrations'].value.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
});
const shouldShowCTA = computed(() => {
return (
isLinearFeatureEnabled.value &&
!uiSettings.is_linear_cta_modal_dismissed &&
!isLinearIntegrationEnabled.value &&
isAdmin.value
);
});
const tooltipText = computed(() => {
return linkedIssue.value === null && isLinearIntegrationEnabled.value
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
useAlert(error?.message || t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR'));
}
};
const unlinkIssue = async linkId => {
try {
await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.DELETE_ERROR'));
}
};
const onDismissCTAModal = async () => {
try {
await store.dispatch('updateUISettings', {
uiSettings: {
...uiSettings.value,
...{ is_open_ai_cta_modal_dismissed: true },
},
});
} catch (error) {
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.DELETE_ERROR'));
}
};
const openIssue = () => {
if (!isLinearIntegrationEnabled.value) return;
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
if (isLinearIntegrationEnabled.value) {
loadLinkedIssue();
}
});
</script>
@@ -0,0 +1,10 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};
@@ -15,6 +15,7 @@
<span v-if="error" class="message">
{{ error }}
</span>
<slot name="masked" />
</label>
</template>
@@ -1,9 +1,16 @@
<template>
<div class="phone-input--wrap relative">
<div class="phone-input" :class="{ 'has-error': error }">
<div
class="flex items-center dark:bg-slate-900 justify-start rounded-md border border-solid"
:class="
error
? 'border border-solid border-red-400 dark:border-red-400 mb-1'
: 'mb-4 border-slate-200 dark:border-slate-600'
"
>
<div
class="cursor-pointer py-2 pr-1.5 pl-2 rounded-tl-md rounded-bl-md flex items-center justify-center gap-1.5 bg-slate-25 dark:bg-slate-700 h-10 w-14"
@click="toggleCountryDropdown"
@click.prevent="toggleCountryDropdown"
>
<h5 v-if="activeCountry" class="mb-0">
{{ activeCountry.emoji }}
@@ -13,14 +20,15 @@
</div>
<span
v-if="activeDialCode"
class="flex bg-white dark:bg-slate-900 font-medium text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
class="flex bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-100 font-normal text-base leading-normal py-2 pl-2 pr-0"
>
{{ activeDialCode }}
</span>
<input
ref="phoneNumberInput"
:value="phoneNumber"
type="tel"
class="phone-input--field"
class="!mb-0 !rounded-tl-none !rounded-bl-none !border-0 font-normal !w-full dark:!bg-slate-900 text-base !px-1.5 placeholder:font-normal"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@@ -28,24 +36,36 @@
@blur="onBlur"
/>
</div>
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<div
v-if="showDropdown"
ref="dropdown"
v-on-clickaway="onOutsideClick"
tabindex="0"
class="z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900"
@keydown.prevent.up="moveUp"
@keydown.prevent.down="moveDown"
@keydown.prevent.enter="
onSelectCountry(filteredCountriesBySearch[selectedIndex])
"
>
<div class="top-0 sticky bg-white dark:bg-slate-900 p-1">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="dropdown-search"
class="!h-8 !mb-0 !text-sm !border !border-solid !border-slate-200 dark:!border-slate-600"
@input="onSearchCountry"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="country-dropdown--item"
class="flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700"
:class="{
active: country.id === activeCountryCode,
focus: index === selectedIndex,
'bg-slate-50 dark:bg-slate-700': country.id === activeCountryCode,
'bg-slate-25 dark:bg-slate-800': index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
@@ -74,10 +94,8 @@
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
export default {
mixins: [keyboardEventListenerMixins],
props: {
value: {
type: [String, Number],
@@ -102,15 +120,6 @@ export default {
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
@@ -120,6 +129,20 @@ export default {
};
},
computed: {
countries() {
return [
{
name: this.dropdownFirstItemName,
dial_code: '',
emoji: '',
id: '',
},
...countries,
];
},
dropdownFirstItemName() {
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
},
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
@@ -154,12 +177,8 @@ export default {
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
@@ -177,28 +196,14 @@ export default {
onBlur(e) {
this.$emit('blur', e.target.value);
},
dropdownItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedItem() {
if (!this.showDropdown) return [];
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
if (!this.showDropdown) return -1;
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
onSearchCountry() {
// Reset selected index to 0
this.selectedIndex = 0;
},
moveUp() {
if (!this.showDropdown) return;
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
this.scrollToSelected();
},
moveDown() {
if (!this.showDropdown) return;
@@ -206,14 +211,27 @@ export default {
this.selectedIndex + 1,
this.filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
this.scrollToSelected();
},
scrollToSelected() {
this.$nextTick(() => {
const dropdown = this.$refs.dropdown;
const selectedItem = this.$refs.dropdownItem[this.selectedIndex];
const dropdownSearchbarHeight = 40;
if (selectedItem) {
const selectedItemTop = selectedItem.offsetTop;
dropdown.scrollTop = selectedItemTop - dropdownSearchbarHeight;
}
});
},
onSelectCountry(country) {
if (!country || !this.showDropdown) return;
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
this.$refs.phoneNumberInput.focus();
},
setActiveCountry() {
const { phoneNumber } = this;
@@ -224,33 +242,6 @@ export default {
this.activeDialCode = number.countryCallingCode;
}
},
getKeyboardEvents() {
return {
ArrowUp: {
action: e => {
e.preventDefault();
this.moveUp();
},
allowOnFocusedInput: true,
},
ArrowDown: {
action: e => {
e.preventDefault();
this.moveDown();
},
allowOnFocusedInput: true,
},
Enter: {
action: e => {
e.preventDefault();
this.onSelectCountry(
this.filteredCountriesBySearch[this.selectedIndex]
);
},
allowOnFocusedInput: true,
},
};
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
@@ -267,46 +258,3 @@ export default {
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
.phone-input {
@apply flex items-center dark:bg-slate-900 justify-start mb-4 rounded-md border border-solid border-slate-200 dark:border-slate-600;
&.has-error {
@apply border border-solid border-red-400 dark:border-red-400;
}
}
.phone-input--field {
@apply mb-0 rounded-tl-none rounded-bl-none border-0 w-full dark:bg-slate-900 text-base px-1.5;
&::placeholder {
@apply font-normal;
}
}
.country-dropdown {
@apply z-10 absolute h-60 w-[12.5rem] shadow-md overflow-y-auto top-10 rounded px-0 pt-0 pb-1 bg-white dark:bg-slate-900;
.dropdown-search--wrap {
@apply top-0 sticky bg-white dark:bg-slate-900 p-1;
.dropdown-search {
@apply h-8 mb-0 text-sm border border-solid border-slate-200 dark:border-slate-600;
}
}
.country-dropdown--item {
@apply flex items-center h-7 py-0 px-1 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700;
&.active {
@apply bg-slate-50 dark:bg-slate-700;
}
&.focus {
@apply bg-slate-25 dark:bg-slate-800;
}
}
}
}
</style>
@@ -87,7 +87,6 @@
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { SHORTCUT_KEYS } from './constants';
import Hotkey from 'dashboard/components/base/Hotkey.vue';
@@ -95,7 +94,6 @@ export default {
components: {
Hotkey,
},
mixins: [clickaway],
props: {
show: {
type: Boolean,
@@ -0,0 +1,12 @@
import { getCurrentInstance } from 'vue';
export const useTrack = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$track;
};
export function useAlert(message, action) {
bus.$emit('newToastMessage', message, action);
}
@@ -0,0 +1,30 @@
import { getCurrentInstance, reactive, watchEffect } from 'vue';
/**
* Returns the current route location. Equivalent to using `$route` inside
* templates.
*/
export function useRoute() {
const instance = getCurrentInstance();
const route = reactive(Object.assign({}, instance.proxy.$root.$route));
watchEffect(() => {
Object.assign(route, instance.proxy.$root.$route);
});
return route;
}
/**
* Returns the router instance. Equivalent to using `$router` inside
* templates.
*/
export function useRouter() {
const instance = getCurrentInstance();
const router = instance.proxy.$root.$router;
watchEffect(() => {
if (router) {
Object.assign(router, instance.proxy.$root.$router);
}
});
return router;
}
@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { getCurrentInstance } from 'vue';
export const useStore = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$store;
};
export const useStoreGetters = () => {
const store = useStore();
return Object.fromEntries(
Object.keys(store.getters).map(getter => [
getter,
computed(() => store.getters[getter]),
])
);
};
export const mapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};
+1
View File
@@ -30,4 +30,5 @@ export const FEATURE_FLAGS = {
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
INBOUND_EMAILS: 'inbound_emails',
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
};
@@ -52,8 +52,22 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
return null;
};
export const isAConversationRoute = routeName =>
[
export const isAConversationRoute = (
routeName,
includeBase = false,
includeExtended = true
) => {
const baseRoutes = [
'home',
'conversation_mentions',
'conversation_unattended',
'inbox_dashboard',
'label_conversations',
'team_conversations',
'folder_conversations',
'conversation_participating',
];
const extendedRoutes = [
'inbox_conversation',
'conversation_through_mentions',
'conversation_through_unattended',
@@ -62,7 +76,15 @@ export const isAConversationRoute = routeName =>
'conversations_through_team',
'conversations_through_folders',
'conversation_through_participating',
].includes(routeName);
];
const routes = [
...(includeBase ? baseRoutes : []),
...(includeExtended ? extendedRoutes : []),
];
return routes.includes(routeName);
};
export const getConversationDashboardRoute = routeName => {
switch (routeName) {
@@ -106,6 +106,51 @@ describe('isAConversationRoute', () => {
expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false);
});
it('returns true if base conversation route name is provided and includeBase is true', () => {
expect(isAConversationRoute('home', true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true)).toBe(true);
expect(isAConversationRoute('conversation_unattended', true)).toBe(true);
expect(isAConversationRoute('inbox_dashboard', true)).toBe(true);
expect(isAConversationRoute('label_conversations', true)).toBe(true);
expect(isAConversationRoute('team_conversations', true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true)).toBe(true);
});
it('returns false if base conversation route name is provided and includeBase is false', () => {
expect(isAConversationRoute('home', false)).toBe(false);
expect(isAConversationRoute('conversation_mentions', false)).toBe(false);
expect(isAConversationRoute('conversation_unattended', false)).toBe(false);
expect(isAConversationRoute('inbox_dashboard', false)).toBe(false);
expect(isAConversationRoute('label_conversations', false)).toBe(false);
expect(isAConversationRoute('team_conversations', false)).toBe(false);
expect(isAConversationRoute('folder_conversations', false)).toBe(false);
expect(isAConversationRoute('conversation_participating', false)).toBe(
false
);
});
it('returns true if base conversation route name is provided and includeBase and includeExtended is true', () => {
expect(isAConversationRoute('home', true, true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true, true)).toBe(
true
);
expect(isAConversationRoute('conversation_unattended', true, true)).toBe(
true
);
expect(isAConversationRoute('inbox_dashboard', true, true)).toBe(true);
expect(isAConversationRoute('label_conversations', true, true)).toBe(true);
expect(isAConversationRoute('team_conversations', true, true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true, true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true, true)).toBe(
true
);
});
it('returns false if base conversation route name is not provided', () => {
expect(isAConversationRoute('')).toBe(false);
});
});
describe('getConversationDashboardRoute', () => {
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "Loading agents",
"UPDATE": {
"CHANGE_STATUS": "Change status",
"SNOOZE_UNTIL_NEXT_REPLY": "Snooze until next reply.",
"SNOOZE_UNTIL": "Snooze",
"UPDATE_SUCCESFUL": "Conversation status updated successfully.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -48,7 +48,8 @@
"SIDEBAR_SECTIONS": {
"CUSTOM_ATTRIBUTES": "Custom Attributes",
"CONTACT_LABELS": "Contact Labels",
"PREVIOUS_CONVERSATIONS": "Previous Conversations"
"PREVIOUS_CONVERSATIONS": "Previous Conversations",
"NO_RECORDS_FOUND": "No attributes found"
}
},
"EDIT_CONTACT": {
@@ -83,6 +84,7 @@
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
@@ -280,6 +280,7 @@
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"NO_RECORDS_FOUND": "No attributes found",
"UPDATE": {
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"
@@ -0,0 +1,19 @@
{
"DATE_PICKER": {
"APPLY_BUTTON": "Apply",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
"START": "Start Date",
"END": "End Date"
},
"DATE_RANGE_OPTIONS": {
"TITLE": "DATE RANGE",
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"CUSTOM_RANGE": "Custom date range"
}
}
}
@@ -107,6 +107,7 @@
"GENERAL": "General",
"REPORTS": "Reports",
"CONVERSATION": "Conversation",
"BULK_ACTIONS": "Bulk Actions",
"CHANGE_ASSIGNEE": "Change Assignee",
"CHANGE_PRIORITY": "Change Priority",
"CHANGE_TEAM": "Change Team",
@@ -22,7 +22,7 @@
"body": "Add agents to the created inbox."
},
{
"title": "Voila!",
"title": "Voilà!",
"route": "settings_inbox_finish",
"body": "You are all set to go!"
}
@@ -43,7 +43,7 @@
"CHOOSE_PLACEHOLDER": "Select a page from the list",
"INBOX_NAME": "Inbox Name",
"ADD_NAME": "Add a name for your inbox",
"PICK_NAME": "Pick A Name Your Inbox",
"PICK_NAME": "Pick a Name for your Inbox",
"PICK_A_VALUE": "Pick a value"
},
"TWITTER": {
@@ -62,7 +62,7 @@
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Enter your Webhook URL",
"PLACEHOLDER": "Please enter your Webhook URL",
"ERROR": "Please enter a valid URL"
},
"CHANNEL_DOMAIN": {
@@ -143,7 +143,7 @@
"ERROR": "This field is required"
},
"PHONE_NUMBER": {
"LABEL": "Phone number",
"LABEL": "Phone Number",
"PLACEHOLDER": "Please enter the phone number from which message will be sent.",
"ERROR": "Please provide a valid phone number that starts with a `+` sign and does not contain any spaces."
},
@@ -175,12 +175,12 @@
},
"API_KEY": {
"LABEL": "API Key",
"PLACEHOLDER": "Please enter your Bandwith API Key",
"PLACEHOLDER": "Please enter your Bandwidth API Key",
"ERROR": "This field is required"
},
"API_SECRET": {
"LABEL": "API Secret",
"PLACEHOLDER": "Please enter your Bandwith API Secret",
"PLACEHOLDER": "Please enter your Bandwidth API Secret",
"ERROR": "This field is required"
},
"APPLICATION_ID": {
@@ -239,7 +239,7 @@
},
"WEBHOOK_VERIFY_TOKEN": {
"LABEL": "Webhook Verify Token",
"PLACEHOLDER": "Enter a verify token which you want to configure for facebook webhooks.",
"PLACEHOLDER": "Enter a verify token which you want to configure for Facebook webhooks.",
"ERROR": "Please enter a valid value."
},
"API_KEY": {
@@ -269,7 +269,7 @@
},
"WEBHOOK_URL": {
"LABEL": "Webhook URL",
"SUBTITLE": "Configure the URL where you want to recieve callbacks on events.",
"SUBTITLE": "Configure the URL where you want to receive callbacks on events.",
"PLACEHOLDER": "Webhook URL"
},
"SUBMIT_BUTTON": "Create API Channel",
@@ -279,7 +279,7 @@
},
"EMAIL_CHANNEL": {
"TITLE": "Email Channel",
"DESC": "Integrate you email inbox.",
"DESC": "Integrate your email inbox.",
"CHANNEL_NAME": {
"LABEL": "Channel Name",
"PLACEHOLDER": "Please enter a channel name",
@@ -345,7 +345,7 @@
"AGENTS": {
"TITLE": "Agents",
"DESC": "Here you can add agents to manage your newly created inbox. Only these selected agents will have access to your inbox. Agents which are not part of this inbox will not be able to see or respond to messages in this inbox when they login. <br> <b>PS:</b> As an administrator, if you need access to all inboxes, you should add yourself as agent to all inboxes that you create.",
"VALIDATION_ERROR": "Add atleast one agent to your new Inbox",
"VALIDATION_ERROR": "Add at least one agent to your new Inbox",
"PICK_AGENTS": "Pick agents for the inbox"
},
"DETAILS": {
@@ -406,7 +406,7 @@
},
"SENDER_NAME_SECTION": {
"TITLE": "Sender name",
"SUB_TEXT": "Select the name shown to the your customer when they receive emails from your agents.",
"SUB_TEXT": "Select the name shown to your customer when they receive emails from your agents.",
"FOR_EG": "For eg:",
"FRIENDLY": {
"TITLE": "Friendly",
@@ -508,12 +508,12 @@
"ALLOW_MESSAGES_AFTER_RESOLVED": "Allow messages after conversation resolved",
"ALLOW_MESSAGES_AFTER_RESOLVED_SUB_TEXT": "Allow the end-users to send messages even after the conversation is resolved.",
"WHATSAPP_SECTION_SUBHEADER": "This API Key is used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the updated key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_UPDATE_SUBHEADER": "Enter the new API key to be used for the integration with the WhatsApp APIs.",
"WHATSAPP_SECTION_TITLE": "API Key",
"WHATSAPP_SECTION_UPDATE_TITLE": "Update API Key",
"WHATSAPP_SECTION_UPDATE_PLACEHOLDER": "Enter the new API Key here",
"WHATSAPP_SECTION_UPDATE_BUTTON": "Update",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verify Token",
"WHATSAPP_WEBHOOK_TITLE": "Webhook Verification Token",
"WHATSAPP_WEBHOOK_SUBHEADER": "This token is used to verify the authenticity of the webhook endpoint.",
"UPDATE_PRE_CHAT_FORM_SETTINGS": "Update Pre Chat Form Settings"
},
@@ -585,7 +585,7 @@
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
"UPDATE": "Update IMAP settings",
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
"TOGGLE_HELP": "Enabling IMAP will help the user to receive email",
"EDIT": {
"SUCCESS_MESSAGE": "IMAP settings updated successfully",
"ERROR_MESSAGE": "Unable to update IMAP settings"
@@ -10,6 +10,7 @@
"PASSWORD_UPDATE_SUCCESS": "Your password has been changed successfully",
"AFTER_EMAIL_CHANGED": "Your profile has been updated successfully, please login again as your login credentials are changed",
"FORM": {
"PICTURE": "Profile Picture",
"AVATAR": "Profile Image",
"ERROR": "Please fix form errors",
"REMOVE_IMAGE": "Remove",
@@ -56,13 +57,19 @@
},
"ACCESS_TOKEN": {
"TITLE": "Access Token",
"NOTE": "This token can be used if you are building an API based integration"
"NOTE": "This token can be used if you are building an API based integration",
"COPY": "Copy"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
"ALERT_TYPES": {
"NONE": "None",
"MINE": "Assigned",
"ALL": "All"
},
"ALERT_TYPE": {
"TITLE": "Alert events:",
"TITLE": "Alert events for conversations:",
"NONE": "None",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
@@ -88,6 +95,23 @@
"SLA_MISSED_NEXT_RESPONSE": "Send email notifications when a conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "Send email notifications when a conversation misses resolution SLA"
},
"NOTIFICATIONS": {
"TITLE": "Notification preferences",
"TYPE_TITLE": "Notification type",
"EMAIL": "Email",
"PUSH": "Push notification",
"TYPES": {
"CONVERSATION_CREATED": "A new conversation is created",
"CONVERSATION_ASSIGNED": "A conversation is assigned to you",
"CONVERSATION_MENTION": "You are mentioned in a conversation",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "A new message is created in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "A new message is created in a participating conversation",
"SLA_MISSED_FIRST_RESPONSE": "A conversation misses first response SLA",
"SLA_MISSED_NEXT_RESPONSE": "A conversation misses next response SLA",
"SLA_MISSED_RESOLUTION": "A conversation misses resolution SLA"
},
"BROWSER_PERMISSION": "Enable push notifications for your browser so youre able to receive them"
},
"API": {
"UPDATE_SUCCESS": "Your notification preferences are updated successfully",
"UPDATE_ERROR": "There is an error while updating the preferences, please try again"
@@ -320,7 +344,6 @@
"GO_TO_REPORTS_SIDEBAR": "Go to Reports sidebar",
"MOVE_TO_NEXT_TAB": "Move to next tab in conversation list",
"GO_TO_SETTINGS": "Go to Settings",
"SWITCH_CONVERSATION_STATUS": "Switch to the next conversation status",
"SWITCH_TO_PRIVATE_NOTE": "Switch to Private Note",
"SWITCH_TO_REPLY": "Switch to Reply",
"TOGGLE_SNOOZE_DROPDOWN": "Toggle snooze dropdown"
@@ -6,6 +6,18 @@
"DESCRIPTION": "Service Level Agreements (SLAs) are contracts that define clear expectations between your team and customers. They establish standards for response and resolution times, creating a framework for accountability and ensures a consistent, high-quality experience.",
"LEARN_MORE": "Learn more about SLA",
"LOADING": "Fetching SLAs",
"PAYWALL": {
"TITLE": "Upgrade to create SLAs",
"AVAILABLE_ON": "The SLA feature is only available in the Business and Enterprise plans.",
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
"UPGRADE_NOW": "Upgrade now",
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "The SLA feature is only available in the paid plans.",
"UPGRADE_PROMPT": "Upgrade to a paid plan to access advanced features like audit logs, agent capacity, and more.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"LIST": {
"404": "There are no SLAs available in this account.",
"EMPTY": {
@@ -93,4 +105,4 @@
"HIDE": "Hide {count} rows"
}
}
}
}
@@ -87,8 +87,8 @@
},
"CONDITION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
"CONTACT_CUSTOM_ATTR_LABEL": "Contact Custom Attributes",
"CONVERSATION_CUSTOM_ATTR_LABEL": "Conversation Custom Attributes"
"CONTACT_CUSTOM_ATTR_LABEL": "سمة مخصصة لجهة اتصال",
"CONVERSATION_CUSTOM_ATTR_LABEL": "سمة مخصصة للمحادثة"
},
"ACTION": {
"DELETE_MESSAGE": "يجب أن يكون لديك على الأقل شرط واحد للحفظ",
@@ -111,7 +111,7 @@
"UPLOAD_ERROR": "تعذر تحميل المرفق، الرجاء المحاولة مرة أخرى",
"LABEL_IDLE": "ارفع المرفق",
"LABEL_UPLOADING": "جاري الرفع...",
"LABEL_UPLOADED": "Successfully Uploaded",
"LABEL_UPLOADED": "تم الرفع بنجاح",
"LABEL_UPLOAD_FAILED": "فشل الرفع"
}
}
@@ -17,7 +17,7 @@
"AGENT_LIST_LOADING": "جاري تحميل الوكلاء",
"UPDATE": {
"CHANGE_STATUS": "تغيير الحالة",
"SNOOZE_UNTIL_NEXT_REPLY": "غفوة حتى الرد القادم.",
"SNOOZE_UNTIL": "غفوة",
"UPDATE_SUCCESFUL": "تم تحديث حالة المحادثة بنجاح.",
"UPDATE_FAILED": "Failed to update conversations. Please try again."
},
@@ -1,8 +1,8 @@
{
"CANNED_MGMT": {
"HEADER": "الردود السريعة",
"HEADER_BTN_TXT": "Add canned response",
"LOADING": "Fetching canned responses...",
"HEADER_BTN_TXT": "إضافة رد جاهز",
"LOADING": "جاري جلب الردود الجاهزة...",
"SEARCH_404": "لا توجد عناصر مطابقة لهذا الاستعلام.",
"SIDEBAR_TXT": "<p><b>Canned Responses</b> </p><p> Canned Responses are pre-written reply templates that help you quickly respond to a conversation. To insert a canned response during a chat, agents can type a short code preceded by a '/' character. </p><p> You can manage your canned responses from this page or create new ones using the \"Add canned response\" button.</p><p>Open the <a target=\"_blank\" href=\"https://www.chatwoot.com/hc/chatwoot-user-guide-cloud-version/articles/1677501325-how-to-create-saved-reply-templates-with-canned-responses\">Canned Responses handbook</a> in another tab for a helping hand.</p><p>Also, check out the all-new <a href=\"https://www.chatwoot.com/tools/canned-responses-library\" target=\"_blank\">Canned Responses Library</a>.</p>",
"LIST": {
@@ -10,66 +10,66 @@
"TITLE": "إدارة الردود الجاهزة",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"TABLE_HEADER": [
"Short code",
"كود مختصر",
"المحتوى",
"الإجراءات"
]
},
"ADD": {
"TITLE": "Add canned response",
"TITLE": "إضافة رد جاهز",
"DESC": "Canned Responses are predefined reply templates which can be used to quickly send out replies to conversations.",
"CANCEL_BUTTON_TEXT": "إلغاء",
"FORM": {
"SHORT_CODE": {
"LABEL": "Short code",
"PLACEHOLDER": "Please enter a short code.",
"ERROR": "Short Code is required."
"LABEL": "كود مختصر",
"PLACEHOLDER": "من فضلك ادخل الكود مختصر.",
"ERROR": "الكود مختصر مطلوب."
},
"CONTENT": {
"LABEL": "رسالة",
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
"ERROR": "Message is required."
"PLACEHOLDER": "من فضلك ادخل نص لرسالة التي ترغب في حفظها كقالب لاستخدامها لاحقاُ.",
"ERROR": "الرسالة مطلوبة."
},
"SUBMIT": "إرسال"
},
"API": {
"SUCCESS_MESSAGE": "Canned response added successfully.",
"SUCCESS_MESSAGE": "تم إضافة الرد المعد مسبقاً بنجاح.",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
}
},
"EDIT": {
"TITLE": "Edit canned response",
"TITLE": "تعديل الرد المعد مسبقاً",
"CANCEL_BUTTON_TEXT": "إلغاء",
"FORM": {
"SHORT_CODE": {
"LABEL": "Short code",
"PLACEHOLDER": "Please enter a shortcode.",
"ERROR": "Short code is required."
"LABEL": "كود مختصر",
"PLACEHOLDER": "من فضلك ادخل الكود مختصر.",
"ERROR": "الكود مختصر مطلوب."
},
"CONTENT": {
"LABEL": "رسالة",
"PLACEHOLDER": "Please write the message you want to save as a template to use later.",
"PLACEHOLDER": "من فضلك ادخل نص لرسالة التي ترغب في حفظها كقالب لاستخدامها لاحقاُ.",
"ERROR": "الرسالة مطلوبة."
},
"SUBMIT": "إرسال"
},
"BUTTON_TEXT": "تعديل",
"API": {
"SUCCESS_MESSAGE": "Canned response is updated successfully.",
"SUCCESS_MESSAGE": "تم تحديث الرد المعد مسبقاً بنجاح.",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
}
},
"DELETE": {
"BUTTON_TEXT": "حذف",
"API": {
"SUCCESS_MESSAGE": "Canned response deleted successfully.",
"SUCCESS_MESSAGE": "تم حذف الرد المعد مسبقاً بنجاح.",
"ERROR_MESSAGE": "تعذر الاتصال بالخادم، الرجاء المحاولة مرة أخرى لاحقاً"
},
"CONFIRM": {
"TITLE": "تأكيد الحذف",
"MESSAGE": "هل أنت متأكد من الحذف ",
"YES": "Yes, delete ",
"NO": "No, keep "
"YES": "نعم, حذف ",
"NO": "لا, قم بالابقاء عليه "
}
}
}
@@ -43,7 +43,7 @@
},
"CHAT_TIME_STAMP": {
"CREATED": {
"LATEST": "Created",
"LATEST": "مضاف",
"OLDEST": "تم إنشاؤها في:"
},
"LAST_ACTIVITY": {
@@ -53,10 +53,10 @@
},
"SORT_ORDER_ITEMS": {
"last_activity_at_asc": {
"TEXT": "Last activity: Oldest first"
"TEXT": "اخر نشاط: آلأقدم أولا"
},
"last_activity_at_desc": {
"TEXT": "Last activity: Newest first"
"TEXT": "اخر نشاط: الأحدث أولا"
},
"created_at_desc": {
"TEXT": "Created at: Newest first"
@@ -111,7 +111,7 @@
},
"LAST_USER_MESSAGE_AT": {
"NAME": "Last user message at",
"LABEL": "Last message"
"LABEL": "أخر رسالة"
}
}
},
@@ -15,7 +15,7 @@
"INITIATED_FROM": "تم البدء من",
"INITIATED_AT": "تم البدء في",
"IP_ADDRESS": "عنوان IP",
"CREATED_AT_LABEL": "Created",
"CREATED_AT_LABEL": "أضيفت",
"NEW_MESSAGE": "رسالة جديدة",
"CONVERSATIONS": {
"NO_RECORDS_FOUND": "لا توجد محادثات سابقة مرتبطة بجهة الاتصال هذه.",
@@ -39,16 +39,17 @@
},
"MERGE_CONTACT": "دمج جهة الاتصال",
"CONTACT_ACTIONS": "إجراءات جهات الاتصال",
"MUTE_CONTACT": "Block Contact",
"UNMUTE_CONTACT": "Unblock Contact",
"MUTE_CONTACT": "احجب جهة الاتصال",
"UNMUTE_CONTACT": "إلغاء حجب جهة الاتصال",
"MUTED_SUCCESS": "This contact is blocked successfully. You will not be notified of any future conversations.",
"UNMUTED_SUCCESS": "This contact is unblocked successfully.",
"UNMUTED_SUCCESS": "تم إلغاء حجب جهة الاتصال بنجاح.",
"SEND_TRANSCRIPT": "إرسال النص",
"EDIT_LABEL": "تعديل",
"SIDEBAR_SECTIONS": {
"CUSTOM_ATTRIBUTES": "سمات مخصصة",
"CONTACT_LABELS": "تصنفيات جهات الاتصال",
"PREVIOUS_CONVERSATIONS": "المحادثات السابقة"
"PREVIOUS_CONVERSATIONS": "المحادثات السابقة",
"NO_RECORDS_FOUND": "لم يتم العثور على سمات"
}
},
"EDIT_CONTACT": {
@@ -75,16 +76,17 @@
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"BUTTON_LABEL": "تصدير",
"TITLE": "تصدير جهات الاتصال",
"DESC": "تصدير جهات الاتصال إلى ملف CSV.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "حدث خطأ، الرجاء المحاولة مرة أخرى",
"CONFIRM": {
"TITLE": "Export Contacts",
"TITLE": "تصدير جهات الاتصال",
"MESSAGE": "Are you sure you want to export all contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "نعم, قم بالتصدير",
"NO": "نعم, إلغاء"
}
},
"DELETE_NOTE": {
@@ -148,15 +150,15 @@
"LABEL": "اسم الشركة"
},
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"PLACEHOLDER": "إدخال اسم الدولة",
"LABEL": "اسم الدولة",
"SELECT_PLACEHOLDER": "اختر",
"REMOVE": "حذف",
"SELECT_COUNTRY": "Select Country"
"SELECT_COUNTRY": "أختر الدولة"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
"PLACEHOLDER": "إدخال اسم المدينة",
"LABEL": "اسم المدينة"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
@@ -197,7 +199,7 @@
},
"INBOX": {
"LABEL": "صندوق الوارد",
"PLACEHOLDER": "Choose source inbox",
"PLACEHOLDER": "اختر صندوق المصدر",
"ERROR": "حدد صندوق الوارد"
},
"SUBJECT": {
@@ -149,7 +149,7 @@
"PRIVATE_NOTE": "إضافة ملاحظة خاصة",
"SEND": "إرسال",
"CREATE": "إضافة ملاحظة",
"INSERT_READ_MORE": "Read more",
"INSERT_READ_MORE": "اقرأ المزيد",
"DISMISS_REPLY": "Dismiss reply",
"REPLYING_TO": "Replying to:",
"TIP_FORMAT_ICON": "عرض محرر النصوص",
@@ -280,6 +280,7 @@
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "إنشاء سمة جديدة",
"NO_RECORDS_FOUND": "لم يتم العثور على سمات",
"UPDATE": {
"SUCCESS": "تم تحديث السمة المخصصة بنجاح",
"ERROR": "غير قادر على تحديث السمة. الرجاء المحاولة مرة أخرى لاحقاً"
@@ -3,11 +3,11 @@
"TITLE": "قيم محادثتك",
"PLACEHOLDER": "أخبرنا المزيد...",
"RATINGS": {
"POOR": "😞 Poor",
"FAIR": "😑 Fair",
"AVERAGE": "😐 Average",
"GOOD": "😀 Good",
"EXCELLENT": "😍 Excellent"
"POOR": "😞 سيئ",
"FAIR": "مقبول😑",
"AVERAGE": "😐 متوسط",
"GOOD": "😀 جيد",
"EXCELLENT": "😍 ممتاز"
}
}
}
@@ -0,0 +1,19 @@
{
"DATE_PICKER": {
"APPLY_BUTTON": "تطبيق",
"CLEAR_BUTTON": "Clear",
"DATE_RANGE_INPUT": {
"START": "Start Date",
"END": "End Date"
},
"DATE_RANGE_OPTIONS": {
"TITLE": "DATE RANGE",
"LAST_7_DAYS": "آخر 7 أيام",
"LAST_30_DAYS": "آخر 30 يوماً",
"LAST_3_MONTHS": "آخر 3 أشهر",
"LAST_6_MONTHS": "آخر 6 أشهر",
"LAST_YEAR": "العام الماضي",
"CUSTOM_RANGE": "تحديد نطاق المدة"
}
}
}

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