Compare commits

..
161 Commits
Author SHA1 Message Date
Sojan 8a0d6f6f50 Merge branch 'release/2.11.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2022-11-16 00:45:34 +00:00
Sojan f0ef497005 Bump version to 2.11.0 2022-11-16 00:40:38 +00:00
Sojan JoseandGitHub 8e2da837d4 chore: Update translations from Crowdin 2022-11-16 00:37:29 +00:00
Sojan JoseandGitHub e7f1a9ab4d chore: Enable Macros for all accounts (#5858)
- migrations to enable macros for all accounts
2022-11-16 00:33:09 +00:00
Tejaswini ChileandGitHub 826a735cdb fix: Assign agent action changes (#5827) 2022-11-15 13:15:27 +05:30
Tejaswini ChileandGitHub 38ab3c36db fix: send label list not object in event data presenter (#5853) 2022-11-15 12:20:06 +05:30
b5f7be0cd2 fix: Full name update when creating a conversation without an email id (#5832)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-14 19:39:46 -08:00
Tejaswini ChileandGitHub efceaec950 fix: Activity message generation for team assignemnt automation (#5846) 2022-11-11 19:07:24 +05:30
Fayaz AhmedandGitHub 6aba352e0d fix: Single select for agents and teams in Macros actions (#5837)
* Ignore settings.json

* Single select dropdown for teams and agents
2022-11-10 20:09:33 +05:30
9eb861a3b7 feat: Custom attributes in automations and refactor (#4548)
* Custom attributes

* Custom Attrs Manifest

* Fix dropdown values for custom attributes

* Handle edit mode for custom attributes

* Ported duplicate logic to a mixin

* fix Code climate issue

* Fix Codeclimate complexity warning

* Bug fix - Custom attributes getting duplicated

* Bug fixes and Code Climate issue fix

* Code Climate Issues Breakdown

* Fix test spec

* Add labels for Custom attributes in dropdown

* Refactor

* Refactor Automion mixin

* Refactor Mixin

* Refactor getOperator

* Fix getOperatorType

* File name method refactor

* Refactor appendNewCondition

* spec update

* Refactor methods

* Mixin Spec update

* Automation Mixins Test Specs

* Mixin Spec Rerun

* Automation validations mixin spec

* Automation helper test spec

* Send custom_attr key

* Fix spec fixtures

* fix: Changes for custom attribute type and lower case search

* fix: Specs

* fix: Specs

* fix: Ruby version change

* fix: Ruby version change

* Removes Lowercased values and fix label value in api payload

* Fix specs

* Fixed Query Spec

* Removed disabled labels if no attributes are present

* Code Climate Fixes

* fix: custom attribute with indifferent access

* fix: custom attribute with indifferent access

* Fix specs

* Minor label fix

* REtrigger circle ci build

* Update app/javascript/shared/mixins/specs/automationMixin.spec.js

* Update app/javascript/shared/mixins/specs/automationMixin.spec.js

* fix: Custom attribute case insensitivity search

* Add missing reset action method to input

* Set team_input to single select instead of multiple

* fix: remove value case check for date,boolean and number data type

* fix: cognitive complexity

* fix: cognitive complexity

* fix: Fixed activity message for automation system

* fix: Fixed activity message for automation system

* fix: Fixed activity message for automation system

* fix: codeclimate

* fix: codeclimate

* fix: action cable events for label update

* fix: codeclimate, conversation modela number of methods

* fix: codeclimate, conversation modela number of methods

* fix: codeclimate, conversation modela number of methods

* fix: codeclimate, conversation modela number of methods

* Fix margin bottom for attachment button

* Remove margin bottom to avoid conflict from macros

* Fix automation action query generator using the right key

* fix: not running message created event for activity message

* fix: not running message created event for activity message

* codeclimate fix

* codeclimate fix

* codeclimate fix

* Update app/javascript/dashboard/mixins/automations/methodsMixin.js

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* Update app/javascript/shared/mixins/specs/automationHelper.spec.js

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* Update app/javascript/dashboard/helper/automationHelper.js

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* Update app/javascript/dashboard/mixins/automations/methodsMixin.js

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Tejaswini <tejaswini@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-11-10 10:53:29 +05:30
Sojan JoseandGitHub 3184c8964d chore: Update translations from Crowdin (#5831) 2022-11-09 20:26:30 -08:00
865346223b fix: Add missing dropdown method (#5830)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-09 17:27:04 -08:00
Nithin David ThomasandGitHub 4f82859bba chore: Design improvements for thumbnail and dropdown (#5822) 2022-11-09 16:52:30 -08:00
47c90e2085 feat: Add a loader till the dashboard app is loaded (#5814)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2022-11-09 10:06:09 -08:00
7352b928da feat: Add assign agent option in macro (#5821)
Co-authored-by: fayazara <fayazara@gmail.com>
2022-11-09 09:52:48 -08:00
Muhsin KelothandGitHub 5febdde938 chore: Add feature flag for mobile v2 (#5797) 2022-11-08 21:56:02 -08:00
Nithin David ThomasandGitHub d39ace5a6b feat: Improve image loading for thumbnails (#5823) 2022-11-08 21:05:13 -08:00
Sojan JoseandGitHub e2059cfc5b fix: SocketError: getaddrinfo: for imap channels (#5824)
fixes: #5431
2022-11-08 20:23:46 -08:00
Sojan JoseandGitHub 2e42821c48 chore: Update translations from Crowdin (#5810) 2022-11-08 09:36:24 -08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
16d59f4bb0 chore(deps): bump loader-utils from 1.4.0 to 1.4.1 (#5816)
Bumps [loader-utils](https://github.com/webpack/loader-utils) from 1.4.0 to 1.4.1.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v1.4.1/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v1.4.0...v1.4.1)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-08 09:33:35 -08:00
0d9ed0674b feat: Add store for conversation watchers (#5808)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-11-08 09:33:14 -08:00
6ff0c93659 feat: Ability to receive location on whatsapp inbox (#5742)
- Ability to  receive location messages on WhatsApp Inbox

ref: https://github.com/chatwoot/chatwoot/issues/3398

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-11-07 21:36:47 -08:00
Nithin David ThomasandGitHub 20406dce01 feat: Adds component to show location based messages (#5809)
- Adds a component to show location-type messages in the dashboard
2022-11-07 20:50:07 -08:00
smartdev58andGitHub b50890d1b5 chore: Update data format for platform API feature management (#5718)
Updated JSON data format for Platform API for account creation update API endpoints features flags to accept false values on each feature. 

fixes: #5717
2022-11-07 17:49:45 -08:00
Tejaswini ChileandGitHub 48373628a1 fix: Macros authorizations (#5779)
Macros policy update.

ref: #5730
2022-11-07 17:46:00 -08:00
CristianDutaandGitHub 479d88a480 fix: Identifier not persisted on customer created via Inbox API Channel (#5804)
- Fixes the identifier not being used to identify the contact, this results in having a new contact created every time the email or phone is not supplied.

Fixes: #5704
2022-11-07 16:02:45 -08:00
Pranav Raj SandGitHub 526722dffa fix: Update invalid payload for template messages (#5802) 2022-11-07 15:21:47 -08:00
Tejaswini ChileandGitHub a23974d8b9 Feat/5733 Add private note action in macros (#5805) 2022-11-07 22:12:10 +05:30
matenautaandGitHub 894234e777 chore: Remove quotes from SMTP_USERNAME (#5800)
Removing quotes related to https://github.com/chatwoot/chatwoot/issues/4787
2022-11-06 16:06:18 -08:00
Nithin David ThomasandGitHub dc3ee3464b feat: Add component for grouped thumbnails (#5796) 2022-11-03 22:38:45 -07:00
Pranav Raj SandGitHub 4bb5e812ba fix: Allow ending the conversation if snoozed or pending (#5793) 2022-11-03 13:47:24 -07:00
Nithin David ThomasandGitHub 8bd5ba187a fix: Fix widget thumbnail not rendering background colors (#5791) 2022-11-02 22:13:12 -07:00
mjattiotandGitHub c121b44df4 fix: GCP Redis managed don't support redis client setname command (#4422)
If using Redis on GCP, actionable will attempt to use the Redis client setname command. However, the command is not supported.
So Introducing `REDIS_DISABLE_CLIENT_COMMAND` environment variable will let you solve this issue. 

ref: https://github.com/rails/rails/issues/38244#issuecomment-575454444
2022-11-02 19:14:51 -07:00
CristianDutaandGitHub 4c43330b15 feat: Add inbox details endpoint (#5549)
This change targets the public API and is related to the Inbox with channel type API.
Exposes public inbox details under /public/api/v1/inboxes/{inbox_identifier}. This allows access to feature flags and business hours configured for the inbox.

ref: #5514
2022-11-02 19:05:03 -07:00
Sojan JoseandGitHub 8b659de73d chore: Use connection_pool for redis (#5790)
fixes: #3199
2022-11-02 17:31:20 -07:00
Tejaswini ChileandGitHub 936c2ec7e2 fix: Ensure Automation rule executions are account scoped (#5768)
This is one possible fix to tackle with cached automation rules when automation gets called on two different accounts with the same event name.

Fixes: https://github.com/chatwoot/product/issues/605
2022-11-02 17:24:35 -07:00
smartdev58andGitHub 9c0cce0392 chore: Mark conversations as pending instead of reopen when bot is present (#5751)
With this change, conversations are marked as pending instead of reopening when a bot is connected to that Inbox. 

Fixes: #5668
2022-11-02 13:54:56 -07:00
Arkadiy AyvazyanandGitHub 86ca7f4a8d fix: Autoloading during initialization deprecation warning (#5628)
Autoloading during initialization deprecation warning fix
fixes: #4012
2022-11-01 20:19:42 -07:00
Sojan JoseandGitHub 6cfd594d85 fix: flaky test whatsapp_cloud_service_spec.rb:17 (#5786)
Fix flaky test: flaky /spec/services/whatsapp/providers/whatsapp_cloud_service_spec.rb:17

example build: https://app.circleci.com/pipelines/github/chatwoot/chatwoot/48955/workflows/a2959d25-19ea-4812-ba15-5aac69c43265/jobs/49523

Cause: factory bot can create phone numbers of length 15 digits, which is valid e164, while our regex only handled up to 14 digits
2022-11-01 19:51:42 -07:00
Sivin VargheseandGitHub e1190fd9bf fix: Text colour is getting merged with the light background colour (#5773) 2022-11-02 05:52:00 +05:30
Stephen Paul WeberandGitHub f2753df8df chore: Use multiple connections in Redis connection pool (#5574)
- The initializer set up a connection pool, but both pools created namespace wrappers around a single global connection. Splitting them up.
2022-11-01 16:49:26 -07:00
Tejaswini ChileandGitHub 00e06e5139 fix: Unsupported message type (#5783)
- fix handling unsupported message types
2022-11-01 15:21:45 -07:00
Fayaz AhmedandGitHub be516a5ea6 fix: Filename issue while editing a macro with attachment action (#5775) 2022-11-01 16:20:09 +05:30
givetimetolifeandGitHub f8d9a27d7a fix: db/seeds.rb field contact_inbox is undefined (#5743)
fixes rails db:chatwoot_prepare error: field contact_inbox is undefined
2022-11-01 00:52:07 -07:00
Muhsin KelothandGitHub 92724576af fix: Update conversation read status indicator logic (#5777) 2022-10-31 09:35:48 -07:00
Tejaswini ChileandGitHub a0606d36f6 fix: Set message sender to macros excution (#5769) 2022-10-31 19:33:12 +05:30
Muhsin KelothandGitHub 2073a23d5c chore: Fix Thumbnail component specs (#5776) 2022-10-28 14:19:12 +05:30
Sivin VargheseandGitHub b20f5e5cef feat: Adds URL validation for domain and home page links (#5761) 2022-10-28 06:21:38 +05:30
Nithin David ThomasandGitHub 3b09840d39 chore: Fix import paths in storybook (#5772) 2022-10-28 01:42:24 +05:30
2aa99ee137 fix: Fix timestamp auto-update in conversations chat list (#5640)
* fix: Fix timestamp auto-update

* fix: rewrite `setInterval` to `setTimeout`

* fix: change refresh time logic

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-28 01:05:39 +05:30
David KubešandGitHub 89d7e4ead6 chore: refactor thumbnail (#5682) 2022-10-28 01:02:23 +05:30
Sojan JoseandGitHub 12cd15b6ad chore: Disable email processing for suspended accounts (#5762)
- Disable email processing for suspended accounts
2022-10-26 03:40:47 -07:00
Sivin VargheseandGitHub 352558dd11 fix: Update color for the action button used in card messages (#5740) 2022-10-25 16:17:05 -07:00
Pranav Raj SandGitHub bedb2cab63 Use @page-change instead of on-page-change (#5749) 2022-10-26 03:46:09 +05:30
abe439594e fix: Add preference to choose browser lang for widget settings page (#5726)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-25 15:04:29 -07:00
bcde84b5b5 feat: Add ability to paste file/image from clipboard (#5627)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2022-10-25 13:35:11 +05:30
06e2219110 chore: Improve macros stability (#5700)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-24 23:49:19 -07:00
c3ec1d4f8a feat: Add the ability for the agents to execute a macro (#5698)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-24 20:33:59 -07:00
Nithin David ThomasandGitHub d54392cb53 fix: Update max-width to fix overflow for emoji selector (#5727) 2022-10-24 19:59:36 -07:00
Sojan JoseandGitHub 3a71fe3260 chore: Stop duplicate websocket updates for conversation updates (#5711)
- remove extra dispatch for label updates
2022-10-21 18:06:02 -07:00
af020f446e fix: check the content type for the file when uploading from cloud storage (#5378)
When sending the message with audio, only the signed id of the file is sent.
In the back end check only the UploadedFile type.
The attachment has the default file type image, now it gets the content type from the signed id

Fixes: #5375

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-10-21 18:05:36 -07:00
Pranav Raj SandGitHub 6823b04e5b fix: Update public partials to fix help center search (#5713) 2022-10-21 17:32:01 -07:00
Nithin David ThomasandGitHub 8d5a9a9daa fix: Show account switch modal from portal dashboard (#5712) 2022-10-21 17:24:52 -07:00
c3426929d7 chore: Refactor sidebar components used in help center (#5695)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-21 16:43:43 -07:00
4a299a9441 fix: Add an action cable events for label updates (#5694)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-21 16:12:35 -07:00
782165478b fix: Update article count in portal admin dashboard (#5647)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-21 13:43:15 -07:00
Pranav Raj SandGitHub 95cc55d043 fix: Update pagination logic in the help center (#5693) 2022-10-20 20:05:17 -07:00
Tejaswini ChileandGitHub a274a1702a chore: Macros enhancement (#5609)
- Fixed send_attachment and send_email_transcript
- Fixed duplicate activity messages
- Fixed Order of execution

Fixes: #5584
2022-10-20 19:41:48 -07:00
Chad BurggrafandGitHub 4d0b302802 Avoid crashing when AudioContext is not available. (#5641)
#4942. We've also had a number of crash reports show up in our logs related to this. Typically we see "undefined is not a constructor" because of the `window.AudioContext || window.webkitAudioContext` returns `undefined`.
2022-10-20 19:13:20 -07:00
Pranav Raj SandGitHub a8561cd798 fix: Add portal pack to tailwind config (#5686) 2022-10-19 21:49:34 -07:00
fa73b5290c chore: Allow feature flag parameters in platform API for account creation (#5589)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 21:16:36 -07:00
a3277b45af feat: Update conversation attributes in realtime when they changed (#5542)
Co-authored-by: daniel gannage <daniel@pxdel.com>
Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2022-10-19 17:26:10 -07:00
10d86fbb35 chore: Ability to Remove password info from sentinel config (#4550)
Introduce the REDIS_SENTINEL_PASSWORD environment variable to customize the behaviour of sentinel passwords. 

Co-authored-by: EXT02D22861 <nusret.ozates@consultant.turkcell.com.tr>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-10-19 17:25:16 -07:00
22d5703b92 feat: Macros listing and Editor (#5606)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 17:13:13 -07:00
1fb1be3ddc feat: Add search functionality for public portal (#5683)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 17:09:32 -07:00
bce0bb8acb chore: Improve pr and bug report templates (#5556)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 13:45:32 -07:00
Tejaswini ChileandGitHub ceaffe862a Fix: Handled IG unsupported file type (#5650)
We get 'unsupported_type' in the web-hook event only when Instagram faces issues processing the attachments. https://developers.facebook.com/docs/messenger-platform/instagram/features/webhook/ according to their document, we are handling the given types and are ignoring this one for now.

Fixes: #5428
2022-10-19 13:44:17 -07:00
Sojan JoseandGitHub 199f462af4 chore: Update translations from Crowdin (#5663) 2022-10-19 13:27:10 -07:00
David KubešandGitHub c542d2e0ff chore: Refactor urlParamsHelper.js (#5639) 2022-10-19 13:20:40 -07:00
6bc34db932 fix: Make links more accessible on helpcenter public views (#5681)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-19 13:07:44 -07:00
Tejaswini ChileandGitHub 0343acdb7e fix: ensure contact_inbox if contact exists(#5667)
- Fixing Instagram issue for existing contacts in the inbox
2022-10-19 12:56:39 -07:00
f740727177 Added testcases for AddReminder Component (#5538)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-10-20 00:54:08 +05:30
Fayaz AhmedandGitHub 3de8f256cb fix: Avoid overflowing submenus in conversation context menu (#5113)
* Scroll overflowing content inside submenus

* Disable submenus if options are not available
2022-10-19 12:23:53 -07:00
Muhsin KelothandGitHub 2e7ab484bd fix: Discard invalid contact attributes in widget conversation end point (#5664)
Fixes: chatwoot/product#601
2022-10-18 13:16:29 -07:00
smartdev58andGitHub e34e975776 chore: ability to delete user in super admin console
fixes: #4164
2022-10-18 01:05:28 -07:00
Sojan a1ce188dab Merge branch 'release/2.10.0' into develop 2022-10-17 19:38:42 -07:00
Sojan ce9c8645df Merge branch 'release/2.10.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2022-10-17 19:38:31 -07:00
Sojan 2f7a16ae16 Bump version to 2.10.0 2022-10-17 19:31:53 -07:00
Pranav Raj SandGitHub 71ca530292 fix: Fix typo in help center (#5661) 2022-10-17 18:59:22 -07:00
Pranav Raj SandGitHub e19c6d5671 chore: Add editor toggle for API inbox (#5660) 2022-10-17 18:52:51 -07:00
Sojan JoseandGitHub 2423def8e8 chore: Add attachments key to message_created webhook payload (#5659)
- Add attachments key to `message_created` webhook payload
2022-10-17 17:36:56 -07:00
1c44e43c43 fix: Fix overflow issue for category name in article list (#5658)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-17 15:59:18 -07:00
Sivin VargheseandGitHub 444809cc68 fix: Added validation for check box in the pre-chat form (#5648) 2022-10-17 15:00:02 -07:00
Pranav Raj SandGitHub 20b4a91122 chore: Add feature flags in the settings console (#5657) 2022-10-17 14:59:44 -07:00
Nithin David ThomasandGitHub 73f5595762 chore: Sync colors from dashboard to tailwind config (#5656) 2022-10-17 13:13:02 -07:00
Sojan JoseandGitHub 704554d453 chore: Update translations (#5644) 2022-10-17 11:14:29 -07:00
Muhsin KelothandGitHub 706ab872f3 fix: Disable name in pre-chat form if the name is already provided in setUser (#5466) 2022-10-17 20:59:17 +05:30
Nithin David ThomasandGitHub 252eda14c6 chore: Add woot button to message context menu (#5638) 2022-10-16 16:48:32 -07:00
ce3730d640 fix: Avoid email icon getting distorted (#5633)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-14 13:38:08 -07:00
Sojan JoseandGitHub db53af91e7 chore (#5636)
* New translations conversation.json (Latvian)

* New translations bulkActions.json (Portuguese, Brazilian)

* New translations agentMgmt.json (Latvian)

* New translations teamsSettings.json (Latvian)

* New translations contactFilters.json (Latvian)

* New translations automation.json (Latvian)

* New translations attributesMgmt.json (Latvian)

* New translations labelsMgmt.json (Latvian)

* New translations settings.json (Latvian)

* New translations integrations.json (Latvian)

* New translations inboxMgmt.json (Latvian)

* New translations generalSettings.json (Latvian)

* New translations contact.json (Latvian)

* New translations helpCenter.json (Latvian)
2022-10-14 12:46:41 -07:00
David KubešandGitHub a6960dc2d3 chore: Refactor widget (#5621) 2022-10-14 09:13:11 +05:30
Sojan JoseandGitHub e310230f62 chore: Refactor Contact Inbox Builders (#5617)
- Remove duplicate code and move everything to builders
- fixes: #4680
2022-10-13 15:12:04 -07:00
Nithin David ThomasandGitHub 1f271356ca feat: Update the design for dropdown buttons (#5625) 2022-10-13 13:36:42 -07:00
bf4338ef9e feat: Make category name in article table clickable (#5626)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-13 13:35:11 -07:00
Tejaswini ChileandGitHub a533f43fbf fix: Stop raising errors for unsupported Whatsapp messages (#5541)
- Handle unsupported Whatsapp messages
2022-10-13 13:31:49 -07:00
Vishnu NarayananandGitHub 8f4944fda0 chore: revert arm64 docker build in gh action (#5619)
ref: https://github.com/chatwoot/chatwoot/pull/5575

https://github.com/chatwoot/chatwoot/pull/5575#issuecomment-1277208625
2022-10-13 13:46:28 +05:30
Pranav Raj SandGitHub d2fd05ee4e fix: Show webhook url only on WhatsApp inbox (#5618) 2022-10-12 21:45:28 -07:00
Nithin David ThomasandGitHub ee520bdf98 feat: Show last active portal articles when sidebar portal icon is clicked (#5616) 2022-10-12 16:22:44 -07:00
6c048626d0 chore: Replace deprecated functions (#5611)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-12 14:55:59 -07:00
David KubešandGitHub 0c8f744c33 chore: Remove unnecessary methods and polyfills (#5614) 2022-10-12 14:42:06 -07:00
fca629a32a fix: Update timezone to get wday from working_hours (#5605)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-12 14:32:54 -07:00
David KubešandGitHub 1b5a335f93 fix: Update .editorconfig to fix spaces and indent_style (#5612) 2022-10-12 14:00:42 -07:00
Muhsin KelothandGitHub 242de0b3f9 chore: Vscode extension recommendations (#5607)
* Added vscode extension recommendations

* Revert the change

* Revert .gitignore

* Change `volar` to `vetur`
2022-10-12 23:32:24 +05:30
Jordan BroughandGitHub 1bdd59f025 Find by downcased email in SupportMailbox (#5211) 2022-10-12 13:38:18 +05:30
Fayaz AhmedandGitHub 32d885a19b feat: Add macros routes and views (#5604) 2022-10-11 23:20:20 -07:00
Fayaz AhmedandGitHub 6c160ccad5 feat: Add API module and Vuex store for Macros (#5603) 2022-10-11 22:54:17 -07:00
Pranav Raj SandGitHub 9b5c0de0ea chore: Add router views for agent_bots (#5600) 2022-10-11 17:58:52 -07:00
38776906ab chore: Generate webhook-verify-token automatically (#5593)
- Autogenerate webhook verification token in the WhatsAppCloud channel.

Co-authored-by: Sojan <sojan@pepalo.com>
2022-10-11 17:32:31 -07:00
Pranav Raj SandGitHub 5f4b6f2ce4 chore: Add AgentBot API module (#5599) 2022-10-11 17:23:57 -07:00
7419e413f4 fix: Added "None" option in bulk actions assignment menu (#5585)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-11 13:44:35 -07:00
0b5a956e05 chore: Update the design for emoji picker (#4244)
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-11 12:59:28 -07:00
Tejaswini ChileandGitHub 2c8ded98aa fix: Update format in password reset page (#5596) 2022-10-11 11:27:22 -07:00
Sojan JoseandGitHub d727093538 chore: Update translations from Crowdin (#5578) 2022-10-10 15:40:12 -07:00
Sojan JoseandGitHub 5bd5395d31 chore: Add missing indexes for attachments table (#5588)
- index for attachments table
- index for conversations table
2022-10-10 15:23:33 -07:00
Sojan JoseandGitHub 779f815f8e chore: Update translations (#5566) 2022-10-07 09:08:18 -07:00
Jordan BroughandGitHub ce7d9be633 Fix "presence" checks in Channel::TwilioSms (#5206) 2022-10-07 10:22:33 +05:30
Pranav Raj SandGitHub 788b766179 feat: Quickly create canned responses (#5563) 2022-10-05 22:00:15 -07:00
0a9ea6e272 chore(deps): bump google-protobuf from 3.21.2 to 3.21.7 (#5550)
Bumps [google-protobuf](https://github.com/protocolbuffers/protobuf) from 3.21.2 to 3.21.7.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/generate_changelog.py)
- [Commits](https://github.com/protocolbuffers/protobuf/compare/v3.21.2...v3.21.7)

---
updated-dependencies:
- dependency-name: google-protobuf
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-10-05 17:32:00 -07:00
b668723313 chore: Ability to change default account (#5393)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2022-10-05 17:31:12 -07:00
Geophilus DurairajandGitHub bd445216e9 fix: DEPRECATION WARNING: Rendering actions with '.' in the name is deprecated (#5560)
fixes the warning: Rendering actions with '.' in the name is deprecated
2022-10-05 17:24:34 -07:00
Pranav Raj SandGitHub cd4c1ef27e feat: Update the design of mentions with thumbnail (#5551) 2022-10-05 14:18:16 -07:00
Sojan JoseandGitHub 8b0e95ece8 fix: Flakiness in CI pipeline (#5562)
- Fixing the recent flakiness in CI pipelines
2022-10-05 10:59:31 -07:00
7b1630b468 feat: Enable Docker Buildx multi-arch builds with arm64 support(#5545)
Fixes #2575

multi-arch images with arm64 support CE edition images

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2022-10-05 17:07:49 +05:30
Sojan JoseandGitHub c76aed6d5e chore: Update translations from Crowdin (#5546) 2022-10-04 18:14:35 -07:00
8df7547043 feat: Add support for draft messages in reply box (#4440)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Fayaz Ahmed <15716057+fayazara@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-10-03 21:29:02 -07:00
beedfc47bf feat: Allow users to select Cmd+Enter as a hotkey (#4401)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2022-10-03 15:27:34 -07:00
9ea43a2678 chore: Improve Nginx settings for speed and security (#5144)
* fix: Fixes #5138

* Move to helper function

* Improve Nginx settings

* chore: set ssl_prefer_server_ciphers to off
ssl_prefer_server_ciphers should be set to  `off` in a modern context.

ref: https://ssl-config.mozilla.org/#server=nginx&version=1.17.7&config=modern&openssl=1.1.1k&guideline=5.6

Co-authored-by: Vishnu Narayanan <vishnu@chatwoot.com>
2022-10-03 17:43:50 +05:30
Nithin David ThomasandGitHub 705d06ac3c fix: Avoid editor formatting issues when a canned response is edited (#5533) 2022-09-30 15:03:33 -07:00
Tejaswini ChileandGitHub 7b54990ae6 fix: Updated IMAP errors add method (#5520)
fixes: #5519
2022-09-30 11:33:00 -07:00
Jordan BroughandGitHub 4f0360c7a2 chore: Allow setting "users.display_name" in Platform API (#5532) 2022-09-30 11:28:18 -07:00
Tejaswini ChileandGitHub 57fcb79d71 fix: Article slug auto saves (#5524)
- Auto save article slug
2022-09-30 07:25:23 -07:00
1819041f5a fix: "wa_source_id" function return value (#5451)
- Fix contact inbox builder returning invalid WhatsApp source id
- Add specs to cover source id validations

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2022-09-29 10:34:55 -07:00
Sojan JoseandGitHub 74e03f9beb chore: Update translations from Crowdin (#5523) 2022-09-28 21:59:41 -07:00
Chamath K.B. AttanayakaandGitHub 6b47c7b43d fix: Attachment not sending to bots message creation (#5353) 2022-09-28 13:13:29 -07:00
Tejaswini ChileandGitHub fcb9a9ab0c fix: contact is not available for inaccessible sender (#5509)
Fixes: #5508

We can not read contact information because of this error, as the messages echo when the sender sends messages to contacts. We don't have the user's consent until and unless they send messages to us.

So after this result, information about the contact is empty, and we are trying to create a contact inbox for the same, and the error appears.

type: OAuthException, code: 230, message: (#230) User consent is required to access user profile, x-fb-trace-id: AaitxF/whwY [HTTP 403] (Koala::Facebook::ClientError)
2022-09-28 12:50:23 -07:00
Pranav Raj SandGitHub 83eee7df91 chore: Set locale in default_locale (#5515) 2022-09-28 08:29:00 -07:00
Tejaswini ChileandGitHub 543854eaa8 fix: Remove the notification subscription if present (#5510) 2022-09-27 12:36:57 -07:00
Tejaswini ChileandGitHub 336c09e072 fix: Add all articles count to article API (#5497) 2022-09-27 12:35:53 -07:00
Tejaswini ChileandGitHub c1c57fb2cd fix: Add slug to articles (#5500) 2022-09-27 12:27:18 -07:00
Sojan 8e5d8fcdaf Merge branch 'release/2.9.1' into develop 2022-09-22 14:22:40 -07:00
Sojan 7ad5ddaf8a Merge branch 'release/2.9.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2022-09-22 14:22:29 -07:00
Sojan d2d7355e2d Bump version to 2.9.1 2022-09-22 14:18:30 -07:00
Sojan JoseandGitHub 913224ad84 chore: Normalize portal slug to nil (#5487) 2022-09-22 14:08:48 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b463ce5b1a chore(deps): bump commonmarker from 0.23.5 to 0.23.6 (#5480)
Bumps [commonmarker](https://github.com/gjtorikian/commonmarker) from 0.23.5 to 0.23.6.
- [Release notes](https://github.com/gjtorikian/commonmarker/releases)
- [Changelog](https://github.com/gjtorikian/commonmarker/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gjtorikian/commonmarker/compare/v0.23.5...v0.23.6)

---
updated-dependencies:
- dependency-name: commonmarker
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-09-21 20:43:08 -07:00
Tejaswini ChileandGitHub 111016fe4c fix: Add not found status if macro not found (#5473) 2022-09-21 14:10:35 +05:30
Pranav Raj SandGitHub cc4ef14faa chore: Fix link generation logic in help-center (#5470) 2022-09-20 18:23:28 -07:00
2d871a1ed5 fix: Hide conversation links in input_csat message in non-website channels (#5469)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2022-09-20 17:15:32 -07:00
Tejaswini ChileandGitHub c556e9c694 Fix: Redirect to portal with default locale (#5467)
- Redirect to default locale if URL does not provide the locale param
2022-09-20 10:31:39 -07:00
Nithin David ThomasandGitHub d28502b917 Bug: Adds support multiline text input on article edit page (#5465)
* Bug: Scroll issue with article list page

* Adds support multiline text input on article edit page
2022-09-20 22:42:09 +05:30
Pranav Raj SandGitHub 1761100c77 chore: Remove static URLs from the documentation (#5461) 2022-09-19 21:52:01 -07:00
Sojan 3a1e521b4c Merge branch 'release/2.9.0' into develop 2022-09-19 20:37:02 -07:00
1183 changed files with 37727 additions and 9078 deletions
+6 -3
View File
@@ -19,6 +19,7 @@ defaults: &defaults
- COVERAGE: true
- LOG_LEVEL: warn
parallelism: 4
resource_class: large
jobs:
build:
@@ -122,9 +123,11 @@ jobs:
mkdir -p coverage
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
bundle exec rspec --profile 10 \
--out test-results/rspec/rspec.xml \
bundle exec rspec --format progress \
--format RspecJunitFormatter \
--out ~/tmp/test-results/rspec.xml \
-- ${TESTFILES}
no_output_timeout: 30m
- run:
name: Code Climate Test Coverage
command: |
@@ -137,7 +140,7 @@ jobs:
~/tmp/cc-test-reporter before-build
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
yarn test:coverage --profile 10 \
--out test-results/frontend_specs/rspec.xml \
--out ~/tmp/test-results/yarn.xml \
-- ${TESTFILES}
- run:
name: Code Climate Test Coverage
+2
View File
@@ -54,3 +54,5 @@ exclude_patterns:
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'
+2 -2
View File
@@ -7,8 +7,8 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = spaces
indent_style = space
tab_width = 2
[{*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
[*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
indent_size = 2
+6 -1
View File
@@ -34,6 +34,11 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
@@ -51,7 +56,7 @@ RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent
# could user either `email@yourdomain.com` or `BrandName <email@yourdomain.com>`
MAILER_SENDER_EMAIL="Chatwoot <accounts@chatwoot.com>"
MAILER_SENDER_EMAIL=Chatwoot <accounts@chatwoot.com>
#SMTP domain key is set up for HELO checking
SMTP_DOMAIN=chatwoot.com
+35 -11
View File
@@ -6,6 +6,7 @@ labels: 'Bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
@@ -16,11 +17,11 @@ Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
4. See the error
**Expected behavior**
A clear and concise description of what you expected to happen.
Share a clear and concise description of what you expected to happen.
**Screenshots**
@@ -28,27 +29,50 @@ If applicable, add screenshots to help explain your problem.
**Browser logs**
Share the browser logs to debug the issue further
Share the browser logs to debug the issue further.
**Server logs**
Share the server logs to debug the issue further
Share the server logs to debug the issue further.
**Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self hosted installation of Chatwoot. If you are using a self hosted installation of Chatwoot describe the type of deployment (Docker/Linux VM installation/Heroku)
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- [ ] app.chatwoot.com (Chatwoot Cloud)
- [ ] Self-hosted
- - [ ] Linux VM
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
**Smartphone (please complete the following information)** (If applicable)
- Device: [e.g. iPhone6, Pixel7]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Browser [e.g. stock browser, firefox, safari]
- Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context**
Add any other context about the problem here.
+4 -5
View File
@@ -2,8 +2,7 @@
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
Fixes # (issue)
## Type of change
@@ -12,18 +11,18 @@ Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
@@ -58,5 +58,6 @@ jobs:
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}
+3 -4
View File
@@ -39,9 +39,6 @@ public/packs*
*.un~
.jest-cache
#VS Code files
.vscode
# ignore jetbrains IDE files
.idea
@@ -62,4 +59,6 @@ package-lock.json
test/cypress/videos/*
/config/master.key
/config/*.enc
/config/*.enc
.vscode/settings.json
+1
View File
@@ -16,6 +16,7 @@ Metrics/ClassLength:
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/controllers/api/v1/accounts/conversations_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength:
+32
View File
@@ -0,0 +1,32 @@
{
"recommendations": [
// Spell check
"streetsidesoftware.code-spell-checker",
// Better Comments
"aaron-bond.better-comments",
// Rails Test Runner
"davidpallinder.rails-test-runner",
// Eslint
"dbaeumer.vscode-eslint",
// Auto Close Tag
"formulahendry.auto-close-tag",
// Auto Rename Tag
"formulahendry.auto-rename-tag",
// Hight light colors
"naumovs.color-highlight",
// GitLens
"eamodio.gitlens",
// Ruby
"rebornix.ruby",
// Vue
"octref.vetur",
// Prettier
"esbenp.prettier-vscode",
// Dot Env
"mikestead.dotenv",
// HTML CSS Support
"ecmel.vscode-html-css",
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
]
}
+1
View File
@@ -0,0 +1 @@
{}
+1
View File
@@ -174,6 +174,7 @@ group :development, :test do
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '~> 5.0.0'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
+12 -9
View File
@@ -135,7 +135,7 @@ GEM
byebug (11.1.3)
climate_control (1.1.1)
coderay (1.1.3)
commonmarker (0.23.5)
commonmarker (0.23.6)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
@@ -286,9 +286,9 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.21.2)
google-protobuf (3.21.2-x86_64-darwin)
google-protobuf (3.21.2-x86_64-linux)
google-protobuf (3.21.7)
google-protobuf (3.21.7-x86_64-darwin)
google-protobuf (3.21.7-x86_64-linux)
googleapis-common-protos (1.3.12)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
@@ -427,14 +427,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.7)
nokogiri (1.13.9)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.7-arm64-darwin)
nokogiri (1.13.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.7-x86_64-darwin)
nokogiri (1.13.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.7-x86_64-linux)
nokogiri (1.13.9-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
orm_adapter (0.5.0)
@@ -536,6 +536,8 @@ GEM
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.11.0)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.31.2)
json (~> 2.3)
parallel (~> 1.10)
@@ -769,6 +771,7 @@ DEPENDENCIES
responders
rest-client
rspec-rails (~> 5.0.0)
rspec_junit_formatter
rubocop
rubocop-performance
rubocop-rails
@@ -805,4 +808,4 @@ RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.3.17
2.3.16
+29 -16
View File
@@ -1,13 +1,12 @@
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
def perform
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
@source_id ||= generate_source_id
create_contact_inbox if source_id.present?
end
private
@@ -19,23 +18,37 @@ class ContactInboxBuilder
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
@contact.email
email_source_id
when 'Channel::Sms'
@contact.phone_number
when 'Channel::Api'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
def wa_source_id
return unless @contact.phone_number
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
"#{@contact.phone_number}.delete('+')"
@contact.phone_number.delete('+').to_s
end
def twilio_source_id
return unless @contact.phone_number
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@@ -45,11 +58,11 @@ class ContactInboxBuilder
end
end
def create_contact_inbox(source_id)
::ContactInbox.find_or_create_by!(
def create_contact_inbox
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: source_id
source_id: @source_id
)
end
end
@@ -1,25 +1,47 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
# This Builder will create a contact and contact inbox with specified attributes.
# If an existing identified contact exisits, it will be returned.
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
find_or_create_contact_and_contact_inbox
# in case of race conditions where contact is created by another thread
# we will try to find the contact and create a contact inbox
rescue ActiveRecord::RecordNotUnique
find_or_create_contact_and_contact_inbox
end
build_contact_inbox
def find_or_create_contact_and_contact_inbox
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
return @contact_inbox if @contact_inbox
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
end
private
def build_contact_with_contact_inbox
@contact = find_contact || create_contact
@contact_inbox = create_contact_inbox
end
def account
@account ||= inbox.account
end
def create_contact_inbox(contact)
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
def create_contact_inbox
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: @source_id,
hmac_verified: hmac_verified
).perform
end
def update_contact_avatar(contact)
@@ -61,16 +83,4 @@ class ContactBuilder
account.contacts.find_by(phone_number: phone_number)
end
def build_contact_inbox
ActiveRecord::Base.transaction do
contact = find_contact || create_contact
contact_inbox = create_contact_inbox(contact)
update_contact_avatar(contact)
contact_inbox
rescue StandardError => e
Rails.logger.error e
raise e
end
end
end
@@ -22,10 +22,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_contact
build_contact_inbox
build_message
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e
@@ -35,15 +34,12 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
private
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: @sender_id,
inbox: @inbox,
contact_attributes: contact_params
).perform
end
def build_message
@@ -54,19 +50,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
end
def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
@@ -94,7 +82,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
contact_id: @contact_inbox.contact_id
}
end
@@ -105,7 +93,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type,
content: response.content,
source_id: response.identifier,
sender: @outgoing_echo ? nil : contact
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@@ -113,7 +101,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
avatar_url: result['profile_pic']
}
end
@@ -72,6 +72,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
@@ -117,6 +118,13 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",
+7 -1
View File
@@ -35,7 +35,13 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end
@@ -2,7 +2,8 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
@@ -80,4 +81,10 @@ class Messages::Messenger::MessageBuilder
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
end
end
@@ -5,9 +5,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :set_current_page, only: [:index]
def index
@articles_count = @portal.articles.count
@articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present?
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.page(@current_page)
end
def create
@@ -37,13 +38,13 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def article_params
params.require(:article).permit(
:title, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
{ tags: [] }]
:title, :slug, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status, meta: [:title, :description,
{ tags: [] }]
)
end
@@ -5,6 +5,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
before_action :set_current_page, only: [:index]
def index
@current_locale = params[:locale]
@categories = @portal.categories.search(params)
end
@@ -2,8 +2,11 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
before_action :ensure_inbox, only: [:create]
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
end
private
@@ -134,8 +134,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
return if params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create!(contact: @contact, inbox: inbox, source_id: source_id)
ContactInboxBuilder.new(
contact: @contact,
inbox: inbox,
source_id: params[:source_id]
).perform
end
def permitted_params
@@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :contact_inbox, only: [:create]
before_action :inbox, :contact, :contact_inbox, only: [:create]
def index
result = conversation_finder.perform
@@ -109,22 +109,35 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
authorize @conversation.inbox, :show?
end
def inbox
return if params[:inbox_id].blank?
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def contact
return if params[:contact_id].blank?
@contact = Current.account.contacts.find(params[:contact_id])
end
def contact_inbox
@contact_inbox = build_contact_inbox
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
end
def build_contact_inbox
return if params[:contact_id].blank? || params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
authorize inbox, :show?
return if @inbox.blank? || @contact.blank?
ContactInboxBuilder.new(
contact_id: params[:contact_id],
inbox_id: inbox.id,
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
end
@@ -22,7 +22,7 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
def download
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=csat_report.csv'
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download.csv.erb', format: 'csv'
render layout: false, template: 'api/v1/accounts/csat_survey_responses/download', formats: [:csv]
end
private
@@ -1,6 +1,6 @@
class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :fetch_macro, only: [:show, :update, :destroy, :execute]
before_action :check_authorization, only: [:show, :update, :destroy, :execute]
def index
@macros = Macro.with_visibility(current_user, params)
@@ -14,19 +14,34 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
process_attachments
@macro
end
def show; end
def show
head :not_found if @macro.nil?
end
def destroy
@macro.destroy!
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
@@ -40,6 +55,19 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
private
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,
@@ -54,4 +82,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
def fetch_macro
@macro = Current.account.macros.find_by(id: params[:id])
end
def check_authorization
authorize(@macro) if @macro.present?
end
end
@@ -14,7 +14,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.members << agents
end
def show; end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
end
def create
@portal = Current.account.portals.build(portal_params)
@@ -24,7 +24,7 @@ class Api::V1::AccountsController < Api::BaseController
).perform
if @user
send_auth_headers(@user)
render 'api/v1/accounts/create.json', locals: { resource: @user }
render 'api/v1/accounts/create', format: :json, locals: { resource: @user }
else
render_error_response(CustomExceptions::Account::SignupFailed.new({}))
end
@@ -32,7 +32,7 @@ class Api::V1::AccountsController < Api::BaseController
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show.json'
render 'api/v1/accounts/show', format: :json
end
def update
@@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy!
notification_subscription.destroy! if notification_subscription.present?
head :ok
end
@@ -22,6 +22,11 @@ class Api::V1::ProfilesController < Api::BaseController
@user.account_users.find_by!(account_id: availability_params[:account_id]).update!(availability: availability_params[:availability])
end
def set_active_account
@user.account_users.find_by(account_id: profile_params[:account_id]).update(active_at: Time.now.utc)
head :ok
end
private
def set_user
@@ -39,6 +44,7 @@ class Api::V1::ProfilesController < Api::BaseController
:display_name,
:avatar,
:message_signature,
:account_id,
ui_settings: {}
)
end
@@ -50,7 +50,9 @@ class Api::V1::Widget::BaseController < ApplicationController
end
def contact_name
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
return if @contact.email.present? || @contact.phone_number.present? || @contact.identifier.present?
permitted_params.dig(:contact, :name) || (contact_email.split('@')[0] if contact_email.present?)
end
def contact_phone_number
@@ -17,7 +17,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
end
@@ -14,22 +14,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def agents
@report_data = generate_agents_report
generate_csv('agents_report', 'api/v2/accounts/reports/agents.csv.erb')
generate_csv('agents_report', 'api/v2/accounts/reports/agents')
end
def inboxes
@report_data = generate_inboxes_report
generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes.csv.erb')
generate_csv('inboxes_report', 'api/v2/accounts/reports/inboxes')
end
def labels
@report_data = generate_labels_report
generate_csv('labels_report', 'api/v2/accounts/reports/labels.csv.erb')
generate_csv('labels_report', 'api/v2/accounts/reports/labels')
end
def teams
@report_data = generate_teams_report
generate_csv('teams_report', 'api/v2/accounts/reports/teams.csv.erb')
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversations
@@ -43,7 +43,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
def generate_csv(filename, template)
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = "attachment; filename=#{filename}.csv"
render layout: false, template: template, format: 'csv'
render layout: false, template: template, formats: [:csv]
end
def check_authorization
@@ -13,6 +13,8 @@ module RequestExceptionHandler
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action')
rescue ActionController::ParameterMissing => e
render_could_not_create_error(e.message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
+1
View File
@@ -45,6 +45,7 @@ class DashboardController < ActionController::Base
@portal = Portal.find_by(custom_domain: domain)
return unless @portal
@locale = @portal.default_locale
render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return
end
@@ -14,7 +14,7 @@ class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
def render_confirmation_success
send_auth_headers(@confirmable)
render partial: 'devise/auth.json', locals: { resource: @confirmable }
render partial: 'devise/auth', formats: [:json], locals: { resource: @confirmable }
end
def render_confirmation_error
@@ -11,7 +11,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
@recoverable = User.find_by(reset_password_token: reset_password_token)
if @recoverable && reset_password_and_confirmation(@recoverable)
send_auth_headers(@recoverable)
render partial: 'devise/auth.json', locals: { resource: @recoverable }
render partial: 'devise/auth', formats: [:json], locals: { resource: @recoverable }
else
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
end
@@ -16,7 +16,7 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
end
def render_create_success
render partial: 'devise/auth.json', locals: { resource: @resource }
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
private
@@ -2,7 +2,7 @@ class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenVali
def validate_token
# @resource will have been set by set_user_by_token concern
if @resource
render 'devise/token.json'
render 'devise/token', formats: [:json]
else
render_validate_token_error
end
@@ -1,18 +1,17 @@
class Platform::Api::V1::AccountsController < PlatformController
def create
@resource = Account.new(account_params)
update_resource_features
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
render json: @resource
end
def show
render json: @resource
end
def show; end
def update
@resource.update!(account_params)
render json: @resource
@resource.assign_attributes(account_params)
update_resource_features
@resource.save!
end
def destroy
@@ -27,6 +26,18 @@ class Platform::Api::V1::AccountsController < PlatformController
end
def account_params
params.permit(:name, :locale)
permitted_params.except(:features)
end
def update_resource_features
return if permitted_params[:features].blank?
permitted_params[:features].each do |key, value|
value.present? ? @resource.enable_features(key) : @resource.disable_features(key)
end
end
def permitted_params
params.permit(:name, :locale, :domain, :support_email, :status, features: {}, limits: {}, custom_attributes: {})
end
end
@@ -51,6 +51,6 @@ class Platform::Api::V1::UsersController < PlatformController
end
def user_params
params.permit(:name, :email, :password, custom_attributes: {})
params.permit(:name, :display_name, :email, :password, custom_attributes: {})
end
end
@@ -4,10 +4,10 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactBuilder.new(
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier, :identifier_hash)
contact_attributes: permitted_params.except(:identifier_hash)
).perform
end
@@ -3,9 +3,15 @@ class Public::Api::V1::InboxesController < PublicController
before_action :set_contact_inbox
before_action :set_conversation
def show
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:id])
end
private
def set_inbox_channel
return if params[:inbox_id].blank?
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
end
@@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category
before_action :set_category, except: [:index]
before_action :set_article, only: [:show]
layout 'portal'
@@ -20,7 +20,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
end
def set_category
@category = @portal.categories.find_by!(slug: params[:category_slug])
@category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present?
end
def portal
@@ -1,6 +1,7 @@
class Public::Api::V1::PortalsController < PublicController
before_action :ensure_custom_domain_request, only: [:show]
before_action :portal
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show; end
@@ -9,5 +10,12 @@ class Public::Api::V1::PortalsController < PublicController
def portal
@portal ||= Portal.find_by!(slug: params[:slug], archived: false)
@locale = params[:locale] || @portal.default_locale
end
def redirect_to_portal_with_locale
return if params[:locale].present?
redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}"
end
end
+6
View File
@@ -8,6 +8,12 @@ module FileTypeHelper
:file
end
# Used in case of DIRECT_UPLOADS_ENABLED=true
def file_type_by_signed_id(signed_id)
blob = ActiveStorage::Blob.find_signed(signed_id)
file_type(blob&.content_type)
end
def image_file?(content_type)
[
'image/jpeg',
+3
View File
@@ -87,6 +87,9 @@ export default {
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
this.$store.dispatch('setActiveAccount', {
accountId: this.currentAccountId,
});
const {
locale,
latest_chatwoot_version: latestChatwootVersion,
@@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class AgentBotsAPI extends ApiClient {
constructor() {
super('agent_bots', { accountScoped: true });
}
}
export default new AgentBotsAPI();
+9
View File
@@ -147,4 +147,13 @@ export default {
deleteAvatar() {
return axios.delete(endPoints('deleteAvatar').url);
},
setActiveAccount({ accountId }) {
const urlData = endPoints('setActiveAccount');
return axios.put(urlData.url, {
profile: {
account_id: accountId,
},
});
},
};
@@ -40,6 +40,10 @@ const endPoints = {
deleteAvatar: {
url: '/api/v1/profile/avatar',
},
setActiveAccount: {
url: '/api/v1/profile/set_active_account',
},
};
export default page => {
@@ -7,8 +7,8 @@ class CategoriesAPI extends PortalsAPI {
super('categories', { accountScoped: true });
}
get({ portalSlug }) {
return axios.get(`${this.url}/${portalSlug}/categories`);
get({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}/categories?locale=${locale}`);
}
create({ portalSlug, categoryObj }) {
@@ -6,6 +6,10 @@ class PortalsAPI extends ApiClient {
super('portals', { accountScoped: true });
}
getPortal({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}?locale=${locale}`);
}
updatePortal({ portalSlug, portalObj }) {
return axios.patch(`${this.url}/${portalSlug}`, portalObj);
}
@@ -105,6 +105,16 @@ class ConversationApi extends ApiClient {
custom_attributes: customAttributes,
});
}
fetchParticipants(conversationId) {
return axios.get(`${this.url}/${conversationId}/participants`);
}
updateParticipants({ conversationId, userIds }) {
return axios.patch(`${this.url}/${conversationId}/participants`, {
user_ids: userIds,
});
}
}
export default new ConversationApi();
+16
View File
@@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class MacrosAPI extends ApiClient {
constructor() {
super('macros', { accountScoped: true });
}
executeMacro({ macroId, conversationIds }) {
return axios.post(`${this.url}/${macroId}/execute`, {
conversation_ids: conversationIds,
});
}
}
export default new MacrosAPI();
@@ -0,0 +1,13 @@
import AgentBotsAPI from '../agentBots';
import ApiClient from '../ApiClient';
describe('#AgentBotsAPI', () => {
it('creates correct instance', () => {
expect(AgentBotsAPI).toBeInstanceOf(ApiClient);
expect(AgentBotsAPI).toHaveProperty('get');
expect(AgentBotsAPI).toHaveProperty('show');
expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete');
});
});
@@ -0,0 +1,14 @@
import macros from '../macros';
import ApiClient from '../ApiClient';
describe('#macrosAPI', () => {
it('creates correct instance', () => {
expect(macros).toBeInstanceOf(ApiClient);
expect(macros).toHaveProperty('get');
expect(macros).toHaveProperty('create');
expect(macros).toHaveProperty('update');
expect(macros).toHaveProperty('delete');
expect(macros).toHaveProperty('show');
expect(macros.url).toBe('/api/v1/macros');
});
});
@@ -113,9 +113,22 @@ $default-button-height: 4.0rem;
}
&.clear {
color: var(--w-700);
&.secondary {
color: var(--s-700)
}
&.success {
color: var(--g-700)
}
&.alert {
color: var(--r-700)
}
&.warning {
color: var(--y-600);
color: var(--y-700)
}
&:hover {
@@ -146,6 +159,8 @@ $default-button-height: 4.0rem;
&.small {
height: var(--space-large);
padding-bottom: var(--space-smaller);
padding-top: var(--space-smaller);
}
&.large {
@@ -14,15 +14,9 @@
}
.modal--close {
border-radius: 50%;
color: $color-heading;
cursor: pointer;
font-size: $font-size-big;
line-height: $space-normal;
padding: $space-normal;
position: absolute;
right: $space-micro;
top: $space-micro;
right: $space-small;
top: $space-small;
&:hover {
background: $color-background;
@@ -7,9 +7,13 @@
@click="onBackDropClick"
>
<div :class="modalContainerClassName" @click.stop>
<button class="modal--close" @click="close">
<fluent-icon icon="dismiss" />
</button>
<woot-button
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="modal--close"
@click="close"
/>
<slot />
</div>
</div>
+6 -4
View File
@@ -5,9 +5,12 @@ import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import Label from './ui/Label';
@@ -21,8 +24,6 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
const WootUIKit = {
AvatarUploader,
@@ -31,9 +32,12 @@ const WootUIKit = {
Code,
ColorPicker,
ConfirmDeleteModal,
ConfirmModal,
ContextMenu,
DeleteModal,
DropdownItem,
DropdownMenu,
FeatureToggle,
HorizontalBar,
Input,
Label,
@@ -47,8 +51,6 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
ConfirmModal,
ContextMenu,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys
@@ -73,14 +73,14 @@ export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),
@@ -39,7 +39,7 @@ const primaryMenuItems = accountId => [
label: 'HELP_CENTER.TITLE',
featureFlag: 'help_center',
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'list_all_portals',
toStateName: 'default_portal_articles',
roles: ['administrator'],
},
{
@@ -1,45 +1,58 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const settings = accountId => ({
parentNav: 'settings',
routes: [
'agent_bots',
'agent_list',
'canned_list',
'labels_list',
'settings_inbox',
'attributes_list',
'settings_inbox_new',
'settings_inbox_list',
'settings_inbox_show',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
'settings_inbox_finish',
'settings_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_integrations_dashboard_apps',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'automation_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
'general_settings',
'labels_list',
'macros_edit',
'macros_new',
'macros_wrapper',
'settings_applications_integration',
'settings_applications_webhook',
'settings_applications',
'settings_inbox_finish',
'settings_inbox_list',
'settings_inbox_new',
'settings_inbox_show',
'settings_inbox',
'settings_inboxes_add_agents',
'settings_inboxes_page_channel',
'settings_integrations_dashboard_apps',
'settings_integrations_integration',
'settings_integrations_webhook',
'settings_integrations',
'settings_teams_add_agents',
'settings_teams_edit_finish',
'settings_teams_edit_members',
'settings_teams_edit',
'settings_teams_finish',
'settings_teams_list',
'settings_teams_new',
'settings_teams_add_agents',
'settings_teams_finish',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
'billing_settings_index',
'automation_list',
],
menuItems: [
{
icon: 'briefcase',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
{
icon: 'people',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
icon: 'people-team',
@@ -47,6 +60,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
icon: 'mail-inbox-all',
@@ -54,6 +68,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{
icon: 'tag',
@@ -61,6 +76,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
featureFlag: FEATURE_FLAGS.LABELS,
},
{
icon: 'code',
@@ -70,13 +86,34 @@ const settings = accountId => ({
`accounts/${accountId}/settings/custom-attributes/list`
),
toStateName: 'attributes_list',
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
},
{
icon: 'automation',
label: 'AUTOMATION',
beta: true,
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
},
{
icon: 'bot',
label: 'AGENT_BOTS',
beta: true,
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
},
{
icon: 'flash-settings',
label: 'MACROS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
beta: true,
featureFlag: FEATURE_FLAGS.MACROS,
},
{
icon: 'chat-multiple',
@@ -86,6 +123,7 @@ const settings = accountId => ({
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
},
{
icon: 'flash-on',
@@ -93,6 +131,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'star-emphasis',
@@ -100,6 +139,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'credit-card-person',
@@ -109,13 +149,6 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
],
});
@@ -8,25 +8,33 @@
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div
v-for="account in currentUser.accounts"
:key="account.id"
class="account-selector"
>
<a :href="`/app/accounts/${account.id}/dashboard`">
<fluent-icon
v-if="account.id === accountId"
class="selected--account"
icon="checkmark-circle"
type="solid"
size="24"
/>
<label :for="account.name" class="account--details">
<div class="account--name">{{ account.name }}</div>
<div class="account--role">{{ account.role }}</div>
</label>
</a>
<div class="account-selector--wrap">
<div
v-for="account in currentUser.accounts"
:key="account.id"
class="account-selector"
>
<button
class="button expanded clear link"
@click="onChangeAccount(account.id)"
>
<span class="button__content">
<label :for="account.name" class="account-details--wrap">
<div class="account--name">{{ account.name }}</div>
<div class="account--role">{{ account.role }}</div>
</label>
</span>
<fluent-icon
v-show="account.id === accountId"
class="selected--account"
icon="checkmark-circle"
type="solid"
size="24"
/>
</button>
</div>
</div>
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="modal-footer delete-item"
@@ -58,5 +66,40 @@ export default {
globalConfig: 'globalConfig/get',
}),
},
methods: {
onChangeAccount(accountId) {
const accountUrl = `/app/accounts/${accountId}/dashboard`;
window.location.href = accountUrl;
},
},
};
</script>
<style lang="scss" scoped>
.account-selector--wrap {
margin-top: var(--space-normal);
}
.account-selector {
padding-top: 0;
padding-bottom: 0;
.button {
display: flex;
justify-content: space-between;
padding: var(--space-one) var(--space-normal);
.account-details--wrap {
text-align: left;
.account--name {
cursor: pointer;
font-size: var(--font-size-medium);
font-weight: var(--font-weight-medium);
line-height: 1;
}
.account--role {
cursor: pointer;
font-size: var(--font-size-mini);
text-transform: capitalize;
}
}
}
}
</style>
@@ -20,6 +20,8 @@
import { frontendURL } from '../../../helper/URLHelper';
import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
export default {
components: {
@@ -61,6 +63,9 @@ export default {
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
@@ -89,7 +94,7 @@ export default {
icon: 'folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.INBOX_MANAGEMENT),
newLinkTag: 'NEW_INBOX',
key: 'inbox',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
@@ -117,7 +122,7 @@ export default {
icon: 'number-symbol',
label: 'LABELS',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
key: 'label',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@@ -141,7 +146,7 @@ export default {
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
@@ -163,7 +168,7 @@ export default {
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_TEAM',
key: 'team',
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
@@ -238,6 +243,9 @@ export default {
toggleAccountModal() {
this.$emit('toggle-accounts');
},
showNewLink(featureFlag) {
return this.isFeatureEnabledonAccount(this.accountId, featureFlag);
},
},
};
</script>
@@ -26,7 +26,7 @@
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
<span v-if="showChildCount" class="count-view">
{{ childItemCount }}
</span>
</span>
@@ -76,7 +76,7 @@ export default {
type: String,
default: '',
},
isHelpCenterSidebar: {
showChildCount: {
type: Boolean,
default: false,
},
@@ -127,11 +127,16 @@ $label-badge-size: var(--space-slab);
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-500);
}
}
.menu-label {
flex-grow: 1;
line-height: var(--space-two);
display: inline-flex;
align-items: center;
}
.inbox-icon {
@@ -175,10 +180,6 @@ $label-badge-size: var(--space-slab);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
line-height: var(--font-size-small);
}
</style>
@@ -1,19 +1,18 @@
<template>
<li class="sidebar-item">
<li v-show="isMenuItemVisible" class="sidebar-item">
<div v-if="hasSubMenu" class="secondary-menu--wrap">
<span class="secondary-menu--header fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<div v-if="menuItem.showNewButton" class="submenu-icons">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="submenu-icon"
@click="onClickOpen"
>
<fluent-icon icon="add" size="16" />
</woot-button>
/>
</div>
</div>
<router-link
@@ -28,15 +27,11 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="isHelpCenterSidebar"
class="count-view"
:class="computedClass"
>
<span v-if="showChildCount(menuItem.count)" class="count-view">
{{ `${menuItem.count}` }}
</span>
<span
v-if="menuItem.label === 'AUTOMATION'"
v-if="menuItem.beta"
data-view-component="true"
label="Beta"
class="beta"
@@ -55,7 +50,7 @@
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:is-help-center-sidebar="isHelpCenterSidebar"
:show-child-count="showChildCount(child.count)"
:child-item-count="child.count"
/>
<router-link
@@ -64,10 +59,10 @@
:to="menuItem.toState"
custom
>
<li>
<li class="menu-item--new">
<a
:href="href"
class="button small clear menu-item--new secondary"
class="button small link clear secondary"
:class="{ 'is-active': isActive }"
@click="e => newLinkClick(e, navigate)"
>
@@ -78,9 +73,6 @@
</a>
</li>
</router-link>
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
</p>
</ul>
</li>
</template>
@@ -104,20 +96,25 @@ export default {
type: Object,
default: () => ({}),
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
isCategoryEmpty: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({ activeInbox: 'getSelectedInbox' }),
...mapGetters({
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSubMenu() {
return !!this.menuItem.children;
},
isMenuItemVisible() {
if (!this.menuItem.featureFlag) {
return true;
}
return this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlag
);
},
isInboxConversation() {
return (
this.$store.state.route.name === 'inbox_conversation' &&
@@ -148,8 +145,8 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isArticlesView() {
return this.$store.state.route.name === this.menuItem.toStateName;
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
computedClass() {
@@ -168,12 +165,11 @@ export default {
}
return ' ';
}
if (this.isHelpCenterSidebar) {
if (this.isArticlesView) {
return 'is-active';
}
return ' ';
if (this.isCurrentRoute) {
return 'is-active';
}
return '';
},
},
@@ -204,11 +200,14 @@ export default {
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
return this.isAdmin && !!item.newLink;
},
onClickOpen() {
this.$emit('open');
},
showChildCount(count) {
return Number.isInteger(count);
},
},
};
</script>
@@ -264,6 +263,11 @@ export default {
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-600);
}
}
.secondary-menu--icon {
@@ -293,22 +297,19 @@ export default {
top: -1px;
}
.sidebar-item .button.menu-item--new {
display: inline-flex;
height: var(--space-medium);
margin: var(--space-smaller) 0;
padding: var(--space-smaller);
color: var(--s-500);
.sidebar-item .menu-item--new {
padding: var(--space-small) 0;
&:hover {
color: var(--w-500);
.button {
display: inline-flex;
color: var(--s-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
margin-left: var(--space-half) !important;
margin-left: var(--space-smaller) !important;
display: inline-block;
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
@@ -327,11 +328,6 @@ export default {
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.submenu-icons {
@@ -343,10 +339,4 @@ export default {
margin-left: var(--space-small);
}
}
.empty-text {
color: var(--s-500);
font-size: var(--font-size-small);
margin: var(--space-smaller);
}
</style>
@@ -0,0 +1,113 @@
<template>
<div class="preview-card--wrap" :class="{ activecard: active }">
<div class="header--wrap" :class="{ active: active }">
<div class="heading-wrap text-block-title">{{ heading }}</div>
<fluent-icon
v-if="active"
icon="checkmark-circle"
type="solid"
size="24"
class="checkmark"
/>
</div>
<div class="content-wrap">
{{ content }}
</div>
<div class="image-wrap">
<img :src="src" class="image" :class="{ activeimage: active }" />
</div>
</div>
</template>
<script>
export default {
props: {
heading: {
type: String,
default: '',
},
content: {
type: String,
default: '',
},
active: {
type: Boolean,
default: false,
},
buttonText: {
type: String,
default: 'Active',
},
src: {
type: String,
default: '',
},
},
};
</script>
<style lang="scss" scoped>
.preview-card--wrap {
border-radius: var(--border-radius-normal);
border: 1px solid var(--color-border);
display: flex;
flex-direction: column;
max-height: 34rem;
max-width: 38rem;
min-width: 24rem;
.header--wrap {
background: var(--s-50);
border-bottom: 1px solid var(--color-border);
border-top-left-radius: var(--border-radius-normal);
border-top-right-radius: var(--border-radius-normal);
display: flex;
height: 4rem;
justify-content: space-between;
padding: var(--space-small);
width: 100%;
}
.active {
background: var(--w-50);
border-bottom: 1px solid var(--w-75);
}
.heading-wrap {
align-items: center;
display: flex;
font-weight: var(--font-weight-medium);
padding: var(--space-smaller);
}
.checkmark {
color: var(--w-500);
}
.content-wrap {
color: var(--s-700);
font-size: var(--font-size-mini);
line-height: 1.4;
padding: var(--space-slab) var(--space-slab) 0 var(--space-slab);
text-align: start;
}
.image-wrap {
padding: var(--space-slab);
}
.image {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-normal);
}
.activeimage {
border: 1px solid var(--w-75);
}
}
.activecard {
background: var(--w-25);
border: 1px solid var(--w-300);
}
</style>
@@ -1,17 +1,15 @@
<template>
<span class="time-ago">
<span> {{ timeAgo }}</span>
<span>{{ timeAgo }}</span>
</span>
</template>
<script>
const ZERO = 0;
const MINUTE_IN_MILLI_SECONDS = 60000;
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
import timeMixin from 'dashboard/mixins/time';
import { differenceInMilliseconds } from 'date-fns';
export default {
name: 'TimeAgo',
@@ -28,51 +26,40 @@ export default {
},
data() {
return {
timeAgo: '',
timeAgo: this.dynamicTime(this.timestamp),
timer: null,
};
},
computed: {
watch: {
timestamp() {
this.timeAgo = this.dynamicTime(this.timestamp);
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, this.refreshTime());
},
refreshTime() {
const timeDiff = differenceInMilliseconds(
new Date(),
new Date(this.timestamp * 1000)
);
const timeDiff = Date.now() - this.timestamp * 1000;
if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS;
}
if (timeDiff > HOUR_IN_MILLI_SECONDS) {
return HOUR_IN_MILLI_SECONDS;
}
if (timeDiff > MINUTE_IN_MILLI_SECONDS) {
return MINUTE_IN_MILLI_SECONDS;
}
return ZERO;
},
},
mounted() {
this.timeAgo = this.dynamicTime(this.timestamp);
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
this.clearTimer();
},
methods: {
createTimer() {
const refreshTime = this.refreshTime;
if (refreshTime > ZERO) {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, refreshTime);
}
},
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
}
return MINUTE_IN_MILLI_SECONDS;
},
},
};
@@ -1,8 +1,5 @@
<template>
<div
class="filter"
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
>
<div class="filter" :class="actionInputStyles">
<div class="filter-inputs">
<select
v-model="action_name"
@@ -21,14 +18,32 @@
<div v-if="showActionInput" class="filter__answer--wrap">
<div v-if="inputType">
<div
v-if="inputType === 'multi_select'"
v-if="inputType === 'search_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="'Select'"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<div
v-else-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT')"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
@@ -36,6 +51,7 @@
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
:option-height="104"
/>
</div>
<input
@@ -60,6 +76,7 @@
</div>
</div>
<woot-button
v-if="!isMacro"
icon="dismiss"
variant="clear"
color-scheme="secondary"
@@ -120,6 +137,10 @@ export default {
type: String,
default: '',
},
isMacro: {
type: Boolean,
default: false,
},
},
computed: {
action_name: {
@@ -146,6 +167,12 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionInputStyles() {
return {
'has-error': this.v.action_params.$dirty && this.v.action_params.$error,
'is-a-macro': this.isMacro,
};
},
},
methods: {
removeAction() {
@@ -165,9 +192,21 @@ export default {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
margin-bottom: var(--space-small);
&.is-a-macro {
margin-bottom: 0;
background: var(--white);
padding: var(--space-zero);
border: unset;
border-radius: unset;
}
}
.filter.error {
.no-margin-bottom {
margin-bottom: 0;
}
.filter.has-error {
background: var(--r-50);
}
@@ -240,6 +279,6 @@ export default {
margin-bottom: var(--space-zero);
}
.action-message {
margin: var(--space-small) 0 0;
margin: var(--space-small) var(--space-zero) var(--space-zero);
}
</style>
@@ -113,5 +113,6 @@ input[type='file'] {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
margin-bottom: 0;
}
</style>
@@ -1,10 +1,6 @@
<template>
<div
class="avatar-container"
:style="[style, customStyle]"
aria-hidden="true"
>
<span>{{ userInitial }}</span>
<div class="avatar-container" :style="style" aria-hidden="true">
{{ userInitial }}
</div>
</template>
@@ -16,69 +12,26 @@ export default {
type: String,
default: '',
},
backgroundColor: {
type: String,
default: '#c2e1ff',
},
color: {
type: String,
default: '#1976cc',
},
customStyle: {
type: Object,
default: undefined,
},
size: {
type: Number,
default: 40,
},
src: {
type: String,
default: '',
},
rounded: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'circle',
},
},
computed: {
style() {
let style = {
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius:
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
if (this.backgroundColor) {
style = { ...style, backgroundColor: this.backgroundColor };
}
if (this.color) {
style = { ...style, color: this.color };
}
return style;
},
userInitial() {
return this.initials || this.initial(this.username);
},
},
methods: {
initial(username) {
const parts = username ? username.split(/[ -]/) : [];
let initials = '';
for (let i = 0; i < parts.length; i += 1) {
initials += parts[i].charAt(0);
}
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substr(0, 2).toUpperCase();
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
@@ -88,11 +41,13 @@ export default {
<style lang="scss" scoped>
.avatar-container {
display: flex;
line-height: 100%;
font-weight: 500;
align-items: center;
justify-content: center;
text-align: center;
background-image: linear-gradient(to top, var(--w-100) 0%, var(--w-75) 100%);
color: var(--w-600);
cursor: default;
}
</style>
@@ -5,6 +5,11 @@
:key="index"
class="dashboard-app--list"
>
<loading-state
v-if="iframeLoading"
:message="$t('DASHBOARD_APPS.LOADING_MESSAGE')"
class="dashboard-app_loading-container"
/>
<iframe
v-if="configItem.type === 'frame' && configItem.url"
:id="`dashboard-app--frame-${index}`"
@@ -16,7 +21,11 @@
</template>
<script>
import LoadingState from 'dashboard/components/widgets/LoadingState';
export default {
components: {
LoadingState,
},
props: {
config: {
type: Array,
@@ -27,6 +36,11 @@ export default {
default: () => ({}),
},
},
data() {
return {
iframeLoading: true,
};
},
computed: {
dashboardAppContext() {
return {
@@ -57,6 +71,7 @@ export default {
);
const eventData = { event: 'appContext', data: this.dashboardAppContext };
frameElement.contentWindow.postMessage(JSON.stringify(eventData), '*');
this.iframeLoading = false;
},
},
};
@@ -73,4 +88,11 @@ export default {
.dashboard-app--list iframe {
border: 0;
}
.dashboard-app_loading-container {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
</style>
@@ -0,0 +1,25 @@
<template>
<div v-if="isFeatureEnabled">
<slot />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
props: {
featureKey: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
accountId: 'getCurrentAccountId',
}),
isFeatureEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, this.featureKey);
},
},
};
</script>
@@ -32,6 +32,7 @@
v-for="attribute in filterAttributes"
:key="attribute.key"
:value="attribute.key"
:disabled="attribute.disabled"
>
{{ attribute.name }}
</option>
@@ -173,6 +174,10 @@ export default {
type: Array,
default: () => [],
},
customAttributeType: {
type: String,
default: '',
},
},
computed: {
attributeKey: {
@@ -83,75 +83,71 @@ export default {
},
pageSize: {
type: Number,
default: 15,
default: 25,
},
totalCount: {
type: Number,
default: 0,
},
onPageChange: {
type: Function,
default: () => {},
},
},
computed: {
isFooterVisible() {
return this.totalCount && !(this.firstIndex > this.totalCount);
},
firstIndex() {
const firstIndex = this.pageSize * (this.currentPage - 1) + 1;
return firstIndex;
return this.pageSize * (this.currentPage - 1) + 1;
},
lastIndex() {
const index = Math.min(this.totalCount, this.pageSize * this.currentPage);
return index;
return Math.min(this.totalCount, this.pageSize * this.currentPage);
},
searchButtonClass() {
return this.searchQuery !== '' ? 'show' : '';
},
hasLastPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
return !!Math.ceil(this.totalCount / this.pageSize);
},
hasFirstPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
return this.currentPage === 1;
},
hasNextPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
},
hasPrevPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
return this.currentPage === 1;
},
},
methods: {
onNextPage() {
if (this.hasNextPage) return;
if (this.hasNextPage) {
return;
}
const newPage = this.currentPage + 1;
this.onPageChange(newPage);
},
onPrevPage() {
if (this.hasPrevPage) return;
if (this.hasPrevPage) {
return;
}
const newPage = this.currentPage - 1;
this.onPageChange(newPage);
},
onFirstPage() {
if (this.hasFirstPage) return;
if (this.hasFirstPage) {
return;
}
const newPage = 1;
this.onPageChange(newPage);
},
onLastPage() {
if (this.hasLastPage) return;
if (this.hasLastPage) {
return;
}
const newPage = Math.ceil(this.totalCount / this.pageSize);
this.onPageChange(newPage);
},
onPageChange(page) {
this.$emit('page-change', page);
},
},
};
</script>
@@ -2,49 +2,47 @@ import { mount } from '@vue/test-utils';
import Avatar from './Avatar.vue';
import Thumbnail from './Thumbnail.vue';
describe(`when there are NO errors loading the thumbnail`, () => {
it(`should render the agent thumbnail`, () => {
describe('Thumbnail.vue', () => {
it('should render the agent thumbnail if valid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_valid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: false,
};
},
});
expect(wrapper.find('#image').exists()).toBe(true);
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(false);
expect(avatarComponent.isVisible()).toBe(false);
});
});
describe(`when there ARE errors loading the thumbnail`, () => {
it(`should render the agent avatar`, () => {
it('should render the avatar component if invalid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_invalid_url.com',
},
data() {
return {
hasImageLoaded: true,
imgError: true,
};
},
});
expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(true);
expect(avatarComponent.isVisible()).toBe(true);
});
});
describe(`when Avatar shows`, () => {
it(`initials shold correspond to username`, () => {
it('should the initial of the name if no image is passed', () => {
const wrapper = mount(Avatar, {
propsData: {
username: 'Angie Rojas',
},
});
expect(wrapper.find('span').text()).toBe('AR');
expect(wrapper.find('div').text()).toBe('AR');
});
});
@@ -1,74 +1,29 @@
<template>
<div class="user-thumbnail-box" :style="{ height: size, width: size }">
<div
:class="thumbnailBoxClass"
:style="{ height: size, width: size }"
:title="title"
>
<!-- Using v-show instead of v-if to avoid flickering as v-if removes dom elements. -->
<img
v-if="!imgError && Boolean(src)"
id="image"
v-show="shouldShowImage"
:src="src"
:class="thumbnailClass"
@error="onImgError()"
@load="onImgLoad"
@error="onImgError"
/>
<Avatar
v-else
v-show="!shouldShowImage"
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
:variant="variant"
/>
<img
v-if="badge === 'instagram_direct_message'"
id="badge"
v-if="badgeSrc"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/instagram-dm.png"
/>
<img
v-else-if="badge === 'facebook'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/messenger.png"
/>
<img
v-else-if="badge === 'twitter-tweet'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/twitter-tweet.png"
/>
<img
v-else-if="badge === 'twitter-dm'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/twitter-dm.png"
/>
<img
v-else-if="badge === 'whatsapp'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/whatsapp.png"
/>
<img
v-else-if="badge === 'sms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/sms.png"
/>
<img
v-else-if="badge === 'Channel::Line'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/line.png"
/>
<img
v-else-if="badge === 'Channel::Telegram'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/telegram.png"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
@@ -83,7 +38,7 @@
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - User name for avatar
* Username - Username for avatar
*/
import Avatar from './Avatar';
import { removeEmoji } from 'shared/helpers/emoji';
@@ -103,7 +58,7 @@ export default {
},
badge: {
type: String,
default: 'fb',
default: '',
},
username: {
type: String,
@@ -121,6 +76,10 @@ export default {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
variant: {
type: String,
default: 'circle',
@@ -128,6 +87,7 @@ export default {
},
data() {
return {
hasImageLoaded: false,
imgError: false,
};
},
@@ -142,6 +102,19 @@ export default {
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
@@ -158,20 +131,34 @@ export default {
this.variant === 'circle' ? 'thumbnail-rounded' : 'thumbnail-square';
return `user-thumbnail ${classname} ${variant}`;
},
thumbnailBoxClass() {
const boxClass = this.variant === 'circle' ? 'is-rounded' : '';
return `user-thumbnail-box ${boxClass}`;
},
shouldShowImage() {
if (!this.src) {
return false;
}
if (this.hasImageLoaded) {
return !this.imgError;
}
return false;
},
},
watch: {
src: {
handler(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
onImgError() {
this.imgError = true;
},
onImgLoad() {
this.hasImageLoaded = true;
},
},
};
</script>
@@ -182,6 +169,10 @@ export default {
max-width: 100%;
position: relative;
&.is-rounded {
border-radius: 50%;
}
.user-thumbnail {
border-radius: 50%;
&.thumbnail-square {
@@ -191,6 +182,7 @@ export default {
width: 100%;
box-sizing: border-box;
object-fit: cover;
vertical-align: initial;
&.border {
border: 1px solid white;
@@ -229,9 +221,5 @@ export default {
.user-online-status--offline {
background: var(--s-500);
}
.user-online-status--offline {
background: var(--s-500);
}
}
</style>
@@ -0,0 +1,89 @@
<template>
<div class="overlapping-thumbnails">
<thumbnail
v-for="user in usersList"
:key="user.id"
v-tooltip="user.name"
:title="user.name"
:src="user.thumbnail"
:username="user.name"
:has-border="true"
:size="size"
:class="`overlapping-thumbnail gap-${gap}`"
/>
<span v-if="showMoreThumbnailsCount" class="thumbnail-more-text">
{{ moreThumbnailsText }}
</span>
</div>
</template>
<script>
import Thumbnail from './Thumbnail';
export default {
components: {
Thumbnail,
},
props: {
usersList: {
type: Array,
default: () => [],
},
size: {
type: String,
default: '24px',
},
showMoreThumbnailsCount: {
type: Boolean,
default: false,
},
moreThumbnailsText: {
type: String,
default: '',
},
gap: {
type: String,
default: 'normal',
validator(value) {
// The value must match one of these strings
return ['normal', '', 'tight'].includes(value);
},
},
},
};
</script>
<style lang="scss" scoped>
.overlapping-thumbnails {
display: flex;
}
.overlapping-thumbnail {
position: relative;
box-shadow: var(--shadow-small);
&:not(:first-child) {
margin-left: var(--space-minus-smaller);
}
.gap-tight {
margin-left: var(--space-minus-small);
}
}
.thumbnail-more-text {
display: inline-flex;
align-items: center;
position: relative;
margin-left: var(--space-minus-small);
padding: 0 var(--space-small);
box-shadow: var(--shadow-small);
background: var(--color-background);
border-radius: var(--space-giga);
border: 1px solid var(--white);
color: var(--s-600);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
}
</style>
@@ -6,7 +6,7 @@
@click="insertMentionNode"
/>
<canned-response
v-if="showCannedMenu"
v-if="showCannedMenu && !isPrivate"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
/>
@@ -28,7 +28,7 @@ import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import { EditorState } from 'prosemirror-state';
import { EditorState, Selection } from 'prosemirror-state';
import { defaultMarkdownParser } from 'prosemirror-markdown';
import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
@@ -61,23 +61,28 @@ export default {
mixins: [eventListenerMixins],
props: {
value: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
isPrivate: { type: Boolean, default: false },
isFormatMode: { type: Boolean, default: false },
enableSuggestions: { type: Boolean, default: true },
},
data() {
return {
lastValue: null,
showUserMentions: false,
showCannedMenu: false,
mentionSearchKey: '',
cannedSearchTerm: '',
editorView: null,
range: null,
state: undefined,
};
},
computed: {
contentFromEditor() {
return addMentionsToMarkdownSerializer(
defaultMarkdownSerializer
).serialize(this.editorView.state.doc);
},
plugins() {
if (!this.enableSuggestions) {
return [];
@@ -102,7 +107,6 @@ export default {
onExit: () => {
this.mentionSearchKey = '';
this.showUserMentions = false;
this.editorView = null;
return false;
},
onKeyDown: ({ event }) => {
@@ -131,7 +135,6 @@ export default {
onExit: () => {
this.cannedSearchTerm = '';
this.showCannedMenu = false;
this.editorView = null;
return false;
},
onKeyDown: ({ event }) => {
@@ -149,54 +152,57 @@ export default {
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
},
value(newValue = '') {
if (newValue !== this.lastValue) {
const { tr } = this.state;
if (this.isFormatMode) {
this.state = createState(
newValue,
this.placeholder,
this.plugins,
this.isFormatMode
);
} else {
tr.insertText(newValue, 0, tr.doc.content.size);
this.state = this.view.state.apply(tr);
}
this.view.updateState(this.state);
if (newValue !== this.contentFromEditor) {
this.reloadState();
}
},
editorId() {
this.reloadState();
},
isPrivate() {
this.reloadState();
},
},
created() {
this.state = createState(this.value, this.placeholder, this.plugins);
},
mounted() {
this.view = new EditorView(this.$refs.editor, {
state: this.state,
dispatchTransaction: tx => {
this.state = this.state.apply(tx);
this.emitOnChange();
},
handleDOMEvents: {
keyup: () => {
this.onKeyup();
},
focus: () => {
this.onFocus();
},
blur: () => {
this.onBlur();
},
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
}
},
},
});
this.createEditorView();
this.editorView.updateState(this.state);
this.focusEditorInputField();
},
methods: {
reloadState() {
this.state = createState(this.value, this.placeholder, this.plugins);
this.editorView.updateState(this.state);
this.focusEditorInputField();
},
createEditorView() {
this.editorView = new EditorView(this.$refs.editor, {
state: this.state,
dispatchTransaction: tx => {
this.state = this.state.apply(tx);
this.emitOnChange();
},
handleDOMEvents: {
keyup: () => {
this.onKeyup();
},
focus: () => {
this.onFocus();
},
blur: () => {
this.onBlur();
},
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();
}
},
},
});
},
handleKeyEvents(e) {
if (hasPressedAltAndPKey(e)) {
this.focusEditorInputField();
@@ -206,47 +212,60 @@ export default {
}
},
focusEditorInputField() {
this.$refs.editor.querySelector('div.ProseMirror-woot-style').focus();
const { tr } = this.editorView.state;
const selection = Selection.atEnd(tr.doc);
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
insertMentionNode(mentionItem) {
if (!this.view) {
if (!this.editorView) {
return null;
}
const node = this.view.state.schema.nodes.mention.create({
userId: mentionItem.key,
userFullName: mentionItem.label,
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
const tr = this.view.state.tr.replaceWith(
const tr = this.editorView.state.tr.replaceWith(
this.range.from,
this.range.to,
node
);
this.state = this.view.state.apply(tr);
this.state = this.editorView.state.apply(tr);
return this.emitOnChange();
},
insertCannedResponse(cannedItem) {
if (!this.view) {
if (!this.editorView) {
return null;
}
const tr = this.view.state.tr.insertText(
const tr = this.editorView.state.tr.insertText(
cannedItem,
this.range.from,
this.range.to
);
this.state = this.view.state.apply(tr);
return this.emitOnChange();
this.state = this.editorView.state.apply(tr);
this.emitOnChange();
// Hacky fix for #5501
this.state = createState(
this.contentFromEditor,
this.placeholder,
this.plugins
);
this.editorView.updateState(this.state);
this.focusEditorInputField();
return false;
},
emitOnChange() {
this.view.updateState(this.state);
this.lastValue = addMentionsToMarkdownSerializer(
defaultMarkdownSerializer
).serialize(this.state.doc);
this.$emit('input', this.lastValue);
this.editorView.updateState(this.state);
this.$emit('input', this.contentFromEditor);
},
hideMentions() {
this.showUserMentions = false;
},
@@ -11,7 +11,6 @@
size="small"
@click="toggleEmojiPicker"
/>
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
<file-upload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
@@ -47,6 +46,16 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="quote"
emoji="🖊️"
color-scheme="secondary"
variant="smooth"
size="small"
@click="$emit('toggle-editor')"
/>
<woot-button
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
@@ -91,17 +100,6 @@
</transition>
</div>
<div class="right-wrap">
<div v-if="isFormatMode" class="enter-to-send--checkbox">
<input
:checked="enterToSendEnabled"
type="checkbox"
value="enterToSend"
@input="toggleEnterToSend"
/>
<label for="enterToSend">
{{ $t('CONVERSATION.REPLYBOX.ENTER_TO_SEND') }}
</label>
</div>
<woot-button
size="small"
:class-names="buttonClass"
@@ -121,13 +119,15 @@ import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload },
@@ -193,7 +193,7 @@ export default {
type: Boolean,
default: false,
},
isFormatMode: {
showEditorToggle: {
type: Boolean,
default: false,
},
@@ -201,10 +201,6 @@ export default {
type: Boolean,
default: false,
},
enterToSendEnabled: {
type: Boolean,
default: true,
},
enableMultipleFileUpload: {
type: Boolean,
default: true,
@@ -215,6 +211,10 @@ export default {
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
@@ -232,7 +232,12 @@ export default {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
return this.showAudioRecorder;
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) && this.showAudioRecorder
);
},
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;
@@ -278,9 +283,6 @@ export default {
this.$refs.upload.$children[1].$el.click();
}
},
toggleEnterToSend() {
this.$emit('toggleEnterToSend', !this.enterToSendEnabled);
},
toggleMessageSignature() {
this.updateUISettings({
send_with_signature: !this.sendWithSignature,
@@ -312,20 +314,6 @@ export default {
.right-wrap {
display: flex;
.enter-to-send--checkbox {
align-items: center;
display: flex;
input {
margin: 0;
}
label {
color: var(--s-500);
font-size: var(--font-size-mini);
}
}
}
::v-deep .file-uploads {
@@ -42,7 +42,7 @@
</div>
<dashboard-app-frame
v-else
:key="currentChat.id"
:key="currentChat.id + '-' + activeIndex"
:config="dashboardApps[activeIndex - 1].content"
:current-chat="currentChat"
/>
@@ -40,6 +40,12 @@
:url="attachment.data_url"
:readable-time="readableTime"
/>
<bubble-location
v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat"
:longitude="attachment.coordinates_long"
:name="attachment.fallback_title"
/>
<bubble-file
v-else
:url="attachment.data_url"
@@ -100,10 +106,11 @@
v-if="isBubble && !isMessageDeleted"
:is-open="showContextMenu"
:show-copy="hasText"
:show-canned-response-option="isOutgoing"
:menu-position="contextMenuPosition"
:message-content="data.content"
@toggle="handleContextMenuClick"
@delete="handleDelete"
@copy="handleCopy"
/>
</div>
</li>
@@ -118,6 +125,7 @@ import BubbleImage from './bubble/Image';
import BubbleFile from './bubble/File';
import BubbleVideo from './bubble/Video.vue';
import BubbleActions from './bubble/Actions';
import BubbleLocation from './bubble/Location';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@@ -126,7 +134,6 @@ import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
components: {
@@ -136,6 +143,7 @@ export default {
BubbleFile,
BubbleVideo,
BubbleMailHead,
BubbleLocation,
ContextMenu,
Spinner,
},
@@ -188,6 +196,10 @@ export default {
return false;
},
message() {
if (this.contentType === 'input_csat') {
return this.$t('CONVERSATION.CSAT_REPLY_MESSAGE');
}
// If the message is an email, emailMessageContent would be present
// In that case, we would use letter package to render the email
if (this.emailMessageContent && this.isIncoming) {
@@ -404,11 +416,6 @@ export default {
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
async handleCopy() {
await copyTextToClipboard(this.data.content);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
this.showContextMenu = false;
},
async retrySendMessage() {
await this.$store.dispatch('sendMessageWithData', this.data);
},
@@ -421,6 +428,8 @@ export default {
<style lang="scss">
.wrap {
> .bubble {
min-width: 128px;
&.is-image,
&.is-video {
padding: 0;
@@ -139,7 +139,6 @@ export default {
listLoadingStatus: 'getAllMessagesLoaded',
getUnreadCount: 'getUnreadCount',
loadingChatList: 'getChatListLoadingStatus',
conversationLastSeen: 'getConversationLastSeen',
}),
inboxId() {
return this.currentChat.inbox_id;
@@ -234,7 +233,6 @@ export default {
return 'arrow-chevron-left';
},
getLastSeenAt() {
if (this.conversationLastSeen) return this.conversationLastSeen;
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
},
@@ -434,7 +432,7 @@ export default {
&::before {
transform: rotate(0deg);
left: var(--space-half);
left: var(--space-smaller);
bottom: var(--space-minus-slab);
}
}
@@ -56,6 +56,7 @@
<woot-message-editor
v-else
v-model="message"
:editor-id="editorStateId"
class="input"
:is-private="isOnPrivateNote"
:placeholder="messagePlaceHolder"
@@ -108,12 +109,11 @@
:recording-audio-state="recordingAudioState"
:is-recording-audio="isRecordingAudio"
:is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
:enter-to-send-enabled="enterToSendEnabled"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
@toggleEnterToSend="toggleEnterToSend"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
/>
<whatsapp-templates
:inbox-id="inbox.id"
@@ -148,19 +148,18 @@ import {
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import {
isEscape,
isEnter,
hasPressedShift,
hasPressedCommandPlusKKey,
} from 'shared/helpers/KeyboardHelpers';
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
export default {
components: {
@@ -214,6 +213,7 @@ export default {
hasSlashCommand: false,
bccEmails: '',
ccEmails: '',
doAutoSaveDraft: () => {},
showWhatsAppTemplatesModal: false,
};
},
@@ -231,6 +231,13 @@ export default {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
assignedAgent: {
@@ -266,9 +273,6 @@ export default {
return !!this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.length;
},
enterToSendEnabled() {
return !!this.uiSettings.enter_to_send_enabled;
},
isPrivate() {
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
return this.isOnPrivateNote;
@@ -342,13 +346,16 @@ export default {
);
},
replyButtonLabel() {
let sendMessageText = this.$t('CONVERSATION.REPLYBOX.SEND');
if (this.isPrivate) {
return this.$t('CONVERSATION.REPLYBOX.CREATE');
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
} else if (this.conversationType === 'tweet') {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.TWEET');
}
if (this.conversationType === 'tweet') {
return this.$t('CONVERSATION.REPLYBOX.TWEET');
}
return this.$t('CONVERSATION.REPLYBOX.SEND');
const keyLabel = isEditorHotKeyEnabled(this.uiSettings, 'cmd_enter')
? '(⌘ + ↵)'
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
replyBoxClass() {
return {
@@ -366,7 +373,7 @@ export default {
);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
@@ -429,10 +436,31 @@ export default {
profilePath() {
return frontendURL(`accounts/${this.accountId}/profile/settings`);
},
editorMessageKey() {
const { editor_message_key: isEnabled } = this.uiSettings;
return isEnabled;
},
commandPlusEnterToSendEnabled() {
return this.editorMessageKey === 'cmd_enter';
},
enterToSendEnabled() {
return this.editorMessageKey === 'enter';
},
conversationId() {
return this.currentChat.id;
},
conversationIdByRoute() {
const { conversation_id: conversationId } = this.$route.params;
return conversationId;
},
editorStateId() {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
},
watch: {
currentChat(conversation) {
const { can_reply: canReply } = conversation;
if (this.isOnPrivateNote) {
return;
}
@@ -445,34 +473,135 @@ export default {
this.setCCEmailFromLastChat();
},
conversationIdByRoute(conversationId, oldConversationId) {
if (conversationId !== oldConversationId) {
this.setToDraft(oldConversationId, this.replyType);
this.getFromDraft();
}
},
message(updatedMessage) {
this.hasSlashCommand =
updatedMessage[0] === '/' && !this.showRichContentEditor;
const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.mentionSearchKey = updatedMessage.substr(1, updatedMessage.length);
this.mentionSearchKey = updatedMessage.substring(1);
this.showMentions = true;
} else {
this.mentionSearchKey = '';
this.showMentions = false;
}
this.doAutoSaveDraft();
},
replyType(updatedReplyType, oldReplyType) {
this.setToDraft(this.conversationIdByRoute, oldReplyType);
this.getFromDraft();
},
},
mounted() {
this.getFromDraft();
// Donot use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
document.addEventListener('keydown', this.handleKeyEvents);
document.addEventListener('paste', this.onPaste);
document.addEventListener('keydown', this.handleKeyEvents);
this.setCCEmailFromLastChat();
this.doAutoSaveDraft = debounce(
() => {
this.saveDraft(this.conversationIdByRoute, this.replyType);
},
500,
true
);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
},
getSavedDraftMessages() {
return LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {};
},
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const savedDraftMessages = this.getSavedDraftMessages();
const key = `draft-${conversationId}-${replyType}`;
const draftToSave = trimContent(this.message || '');
const {
[key]: currentDraft,
...restOfDraftMessages
} = savedDraftMessages;
const updatedDraftMessages = draftToSave
? {
...restOfDraftMessages,
[key]: draftToSave,
}
: restOfDraftMessages;
LocalStorage.set(
LOCAL_STORAGE_KEYS.DRAFT_MESSAGES,
updatedDraftMessages
);
}
},
setToDraft(conversationId, replyType) {
this.saveDraft(conversationId, replyType);
this.message = '';
},
getFromDraft() {
if (this.conversationIdByRoute) {
try {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const savedDraftMessages = this.getSavedDraftMessages();
this.message = `${savedDraftMessages[key] || ''}`;
} catch (error) {
this.message = '';
}
}
},
removeFromDraft() {
if (this.conversationIdByRoute) {
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
const draftMessages = this.getSavedDraftMessages();
const { [key]: toBeRemoved, ...updatedDraftMessages } = draftMessages;
LocalStorage.set(
LOCAL_STORAGE_KEYS.DRAFT_MESSAGES,
updatedDraftMessages
);
}
},
handleKeyEvents(e) {
const keyCode = buildHotKeys(e);
if (keyCode === 'escape') {
this.hideEmojiPicker();
this.hideMentions();
} else if (keyCode === 'meta+k') {
const ninja = document.querySelector('ninja-keys');
ninja.open();
e.preventDefault();
} else if (keyCode === 'enter' && this.isAValidEvent('enter')) {
this.onSendReply();
} else if (
['meta+enter', 'ctrl+enter'].includes(keyCode) &&
this.isAValidEvent('cmd_enter')
) {
this.onSendReply();
}
},
isAValidEvent(selectedKey) {
return (
!this.hasUserMention &&
!this.showCannedMenu &&
this.isFocused &&
isEditorHotKeyEnabled(this.uiSettings, selectedKey)
);
},
onPaste(e) {
const data = e.clipboardData.files;
if (!this.showRichContentEditor && data.length !== 0) {
@@ -492,34 +621,6 @@ export default {
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
handleKeyEvents(e) {
if (isEscape(e)) {
this.hideEmojiPicker();
this.hideMentions();
} else if (isEnter(e)) {
const hasSendOnEnterEnabled =
(this.showRichContentEditor &&
this.enterToSendEnabled &&
!this.hasUserMention &&
!this.showCannedMenu) ||
!this.showRichContentEditor;
const shouldSendMessage =
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;
if (shouldSendMessage) {
e.preventDefault();
this.onSendReply();
}
} else if (hasPressedCommandPlusKKey(e)) {
this.openCommandBar();
}
},
openCommandBar() {
const ninja = document.querySelector('ninja-keys');
ninja.open();
},
toggleEnterToSend(enterToSendEnabled) {
this.updateUISettings({ enter_to_send_enabled: enterToSendEnabled });
},
openWhatsappTemplateModal() {
this.showWhatsAppTemplatesModal = true;
},
@@ -559,11 +660,13 @@ export default {
newMessage += '\n\n' + this.messageSignature;
}
const messagePayload = this.getMessagePayload(newMessage);
this.clearMessage();
if (!this.isPrivate) {
this.clearEmailField();
}
this.sendMessage(messagePayload);
this.clearMessage();
this.hideEmojiPicker();
this.$emit('update:popoutReplyBox', false);
}
@@ -575,6 +678,7 @@ export default {
messagePayload
);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
this.removeFromDraft();
} catch (error) {
const errorMessage =
error?.response?.data?.error || this.$t('CONVERSATION.MESSAGE_ERROR');
@@ -595,7 +699,6 @@ export default {
},
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
const { can_reply: canReply } = this.currentChat;
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
if (this.showRichContentEditor) {
if (this.isRecordingAudio) {
@@ -654,6 +757,7 @@ export default {
},
onBlur() {
this.isFocused = false;
this.saveDraft(this.conversationIdByRoute, this.replyType);
},
onFocus() {
this.isFocused = true;
@@ -879,7 +983,7 @@ export default {
&::before {
transform: rotate(0deg);
left: var(--space-half);
left: var(--space-smaller);
bottom: var(--space-minus-slab);
}
}
@@ -1,49 +1,160 @@
<template>
<mention-box :items="items" @mention-select="handleMentionClick" />
<ul
v-if="items.length"
class="vertical dropdown menu mention--box"
:style="{ top: getTopSpacing() + 'rem' }"
:class="{ 'with-bottom-border': items.length <= 4 }"
>
<li
v-for="(agent, index) in items"
:id="`mention-item-${index}`"
:key="agent.id"
:class="{ active: index === selectedIndex }"
@click="onAgentSelect(index)"
@mouseover="onHover(index)"
>
<div class="mention--thumbnail">
<woot-thumbnail
:src="agent.thumbnail"
:username="agent.name"
size="32px"
/>
</div>
<div class="mention--metadata text-truncate">
<h5 class="text-block-title mention--user-name text-truncate">
{{ agent.name }}
</h5>
<div class="text-truncate mention--email text-truncate">
{{ agent.email }}
</div>
</div>
</li>
</ul>
</template>
<script>
import { mapGetters } from 'vuex';
import MentionBox from '../mentions/MentionBox.vue';
import mentionSelectionKeyboardMixin from '../mentions/mentionSelectionKeyboardMixin';
export default {
components: { MentionBox },
mixins: [mentionSelectionKeyboardMixin],
props: {
searchKey: {
type: String,
default: '',
},
},
data() {
return { selectedIndex: 0 };
},
computed: {
...mapGetters({
agents: 'agents/getVerifiedAgents',
}),
...mapGetters({ agents: 'agents/getVerifiedAgents' }),
items() {
if (!this.searchKey) {
return this.agents.map(agent => ({
label: agent.name,
key: agent.id,
description: agent.email,
}));
return this.agents;
}
return this.agents
.filter(agent =>
agent.name
.toLocaleLowerCase()
.includes(this.searchKey.toLocaleLowerCase())
)
.map(agent => ({
label: agent.name,
key: agent.id,
description: agent.email,
}));
return this.agents.filter(agent =>
agent.name
.toLocaleLowerCase()
.includes(this.searchKey.toLocaleLowerCase())
);
},
},
watch: {
items(newListOfAgents) {
if (newListOfAgents.length < this.selectedIndex + 1) {
this.selectedIndex = 0;
}
},
},
methods: {
handleMentionClick(item = {}) {
this.$emit('click', item);
getTopSpacing() {
if (this.items.length <= 4) {
return -(this.items.length * 5 + 1.7);
}
return -20;
},
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 50 * this.selectedIndex;
},
onHover(index) {
this.selectedIndex = index;
},
onAgentSelect(index) {
this.selectedIndex = index;
this.onSelect();
},
onSelect() {
this.$emit('click', this.items[this.selectedIndex]);
},
},
};
</script>
<style scoped lang="scss">
.mention--box {
background: var(--white);
border-top: 1px solid var(--color-border);
font-size: var(--font-size-small);
left: 0;
line-height: 1.2;
max-height: 20rem;
overflow: auto;
padding: var(--space-small) var(--space-small) 0 var(--space-small);
position: absolute;
width: 100%;
z-index: 100;
&.with-bottom-border {
border-bottom: var(--space-small) solid var(--white);
li {
&:last-child {
margin-bottom: 0;
}
}
}
li {
align-items: center;
border-radius: var(--border-radius-normal);
display: flex;
padding: var(--space-small);
&.active {
background: var(--s-50);
.mention--user-name {
color: var(--s-900);
}
.mention--email {
color: var(--s-800);
}
}
&:last-child {
margin-bottom: var(--space-small);
}
}
}
.mention--thumbnail {
margin-right: var(--space-small);
}
.mention--user-name {
margin-bottom: 0;
}
.mention--email {
color: var(--s-700);
font-size: var(--font-size-mini);
}
.mention--metadata {
flex: 1;
max-width: 100%;
}
</style>
@@ -0,0 +1,78 @@
<template>
<div class="location message-text__wrap">
<div class="icon-wrap">
<fluent-icon icon="location" class="file--icon" size="32" />
</div>
<div class="meta">
<h5 class="text-block-title text-truncate">
{{ name }}
</h5>
<div class="link-wrap">
<a
class="download clear link button small"
rel="noreferrer noopener nofollow"
target="_blank"
:href="mapUrl"
>
{{ $t('COMPONENTS.LOCATION_BUBBLE.SEE_ON_MAP') }}
</a>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
latitude: {
type: Number,
default: undefined,
},
longitude: {
type: Number,
default: undefined,
},
name: {
type: String,
default: '',
},
},
computed: {
mapUrl() {
return `https://maps.google.com/?q=${this.latitude},${this.longitude}`;
},
},
};
</script>
<style lang="scss" scoped>
.location {
display: flex;
flex-direction: row;
padding: var(--space-smaller) 0;
cursor: pointer;
.icon-wrap {
color: var(--s-600);
line-height: 1;
margin: 0 var(--space-smaller);
}
.text-block-title {
margin: 0;
color: var(--s-800);
word-break: break-word;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-right: var(--space-normal);
}
.link-wrap {
display: flex;
}
}
</style>
@@ -17,7 +17,10 @@
@click="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu :option="labelMenuConfig">
<menu-item-with-submenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<template>
<menu-item
v-for="label in labels"
@@ -28,7 +31,10 @@
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="agentMenuConfig">
<menu-item-with-submenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<menu-item
@@ -40,7 +46,10 @@
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="teamMenuConfig">
<menu-item-with-submenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<menu-item
v-for="team in teams"
:key="team.id"
@@ -141,9 +150,19 @@ export default {
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
),
];
},
},
mounted() {
@@ -18,7 +18,7 @@
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label truncate-text">{{ option.label }}</p>
<p class="menu-label text-truncate">{{ option.label }}</p>
</div>
</template>
@@ -50,7 +50,6 @@ export default {
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
overflow: hidden;
.menu-label {
margin: 0;
font-size: var(--font-size-mini);
@@ -1,11 +1,14 @@
<template>
<div class="menu-with-submenu flex-between">
<div
class="menu-with-submenu flex-between"
:class="{ disabled: !subMenuAvailable }"
>
<div class="menu-left">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="menu-label">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div class="submenu">
<div v-if="subMenuAvailable" class="submenu">
<slot />
</div>
</div>
@@ -18,6 +21,10 @@ export default {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
},
};
</script>
@@ -55,6 +62,11 @@ export default {
left: 100%;
top: 0;
display: none;
min-height: min-content;
max-height: var(--space-giga);
overflow-y: auto;
// Need this because Firefox adds a horizontal scrollbar, if a text is truncated inside.
overflow-x: hidden;
}
&:hover {
@@ -73,5 +85,10 @@ export default {
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
}
}
&.disabled {
opacity: 50%;
cursor: not-allowed;
}
}
</style>
@@ -57,7 +57,7 @@
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p>
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
@@ -67,6 +67,15 @@
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<woot-button
@@ -82,7 +91,7 @@
:is-loading="uiFlags.isUpdating"
@click="submit"
>
{{ $t('BULK_ACTION.ASSIGN_LABEL') }}
{{ $t('BULK_ACTION.YES') }}
</woot-button>
</div>
</div>
@@ -131,7 +140,17 @@ export default {
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return this.assignableAgents;
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
@@ -181,7 +181,7 @@ export default {
color: var(--y-700);
font-size: var(--font-size-mini);
margin-top: var(--space-small);
padding: var(--space-half) var(--space-one);
padding: var(--space-smaller) var(--space-small);
}
.popover-animation-enter-active,
@@ -0,0 +1,34 @@
import LocationBubble from '../bubble/Location.vue';
export default {
title: 'Components/Help Center',
component: LocationBubble,
argTypes: {
latitude: {
defaultValue: 1,
control: {
type: 'number',
},
},
longitude: {
defaultValue: 1,
control: {
type: 'number',
},
},
name: {
defaultValue: '420, Dope street',
control: {
type: 'string',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LocationBubble },
template: '<location-bubble v-bind="$props" />',
});
export const LocationBubbleView = Template.bind({});
@@ -20,7 +20,9 @@
</template>
<script>
import mentionSelectionKeyboardMixin from './mentionSelectionKeyboardMixin';
export default {
mixins: [mentionSelectionKeyboardMixin],
props: {
items: {
type: Array,
@@ -39,56 +41,25 @@ export default {
}
},
},
mounted() {
document.addEventListener('keydown', this.keyListener);
},
beforeDestroy() {
document.removeEventListener('keydown', this.keyListener);
},
methods: {
getTopPadding() {
if (this.items.length <= 4) {
return -(this.items.length * 2.8 + 1.7);
return -(this.items.length * 2.9 + 1.7);
}
return -14;
},
isUp(e) {
return e.keyCode === 38 || (e.ctrlKey && e.keyCode === 80); // UP, Ctrl-P
},
isDown(e) {
return e.keyCode === 40 || (e.ctrlKey && e.keyCode === 78); // DOWN, Ctrl-N
},
isEnter(e) {
return e.keyCode === 13;
},
keyListener(e) {
if (this.isUp(e)) {
if (!this.selectedIndex) {
this.selectedIndex = this.items.length - 1;
} else {
this.selectedIndex -= 1;
}
}
if (this.isDown(e)) {
if (this.selectedIndex === this.items.length - 1) {
this.selectedIndex = 0;
} else {
this.selectedIndex += 1;
}
}
if (this.isEnter(e)) {
this.onMentionSelect();
}
this.$el.scrollTop = 28 * this.selectedIndex;
handleKeyboardEvent(e) {
this.processKeyDownEvent(e);
this.$el.scrollTop = 29 * this.selectedIndex;
},
onHover(index) {
this.selectedIndex = index;
},
onListItemSelection(index) {
this.selectedIndex = index;
this.onMentionSelect();
this.onSelect();
},
onMentionSelect() {
onSelect() {
this.$emit('mention-select', this.items[this.selectedIndex]);
},
},
@@ -0,0 +1,39 @@
import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
export default {
mounted() {
document.addEventListener('keydown', this.handleKeyboardEvent);
},
beforeDestroy() {
document.removeEventListener('keydown', this.handleKeyboardEvent);
},
methods: {
moveSelectionUp() {
if (!this.selectedIndex) {
this.selectedIndex = this.items.length - 1;
} else {
this.selectedIndex -= 1;
}
},
moveSelectionDown() {
if (this.selectedIndex === this.items.length - 1) {
this.selectedIndex = 0;
} else {
this.selectedIndex += 1;
}
},
processKeyDownEvent(e) {
const keyPattern = buildHotKeys(e);
if (['arrowup', 'ctrl+p'].includes(keyPattern)) {
this.moveSelectionUp();
e.preventDefault();
} else if (['arrowdown', 'ctrl+n'].includes(keyPattern)) {
this.moveSelectionDown();
e.preventDefault();
} else if (keyPattern === 'enter') {
this.onSelect();
e.preventDefault();
}
},
},
};
@@ -0,0 +1,64 @@
import mentionSelectionKeyboardMixin from '../mentionSelectionKeyboardMixin';
import { shallowMount } from '@vue/test-utils';
const buildComponent = ({ data = {}, methods = {} }) => ({
render() {},
data() {
return data;
},
methods,
mixins: [mentionSelectionKeyboardMixin],
});
describe('mentionSelectionKeyboardMixin', () => {
test('register listeners', () => {
jest.spyOn(document, 'addEventListener');
const Component = buildComponent({});
shallowMount(Component);
// undefined expected as the method is not defined in the component
expect(document.addEventListener).toHaveBeenCalledWith(
'keydown',
undefined
);
});
test('processKeyDownEvent updates index on arrow up', () => {
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
ctrlKey: true,
key: 'p',
preventDefault: jest.fn(),
});
expect(wrapper.vm.selectedIndex).toBe(2);
});
test('processKeyDownEvent updates index on arrow down', () => {
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
key: 'ArrowDown',
preventDefault: jest.fn(),
});
expect(wrapper.vm.selectedIndex).toBe(1);
});
test('processKeyDownEvent calls select methods on Enter Key', () => {
const onSelectMockFn = jest.fn();
const Component = buildComponent({
data: { selectedIndex: 0, items: [1, 2, 3] },
methods: { onSelect: () => onSelectMockFn('enterKey pressed') },
});
const wrapper = shallowMount(Component);
wrapper.vm.processKeyDownEvent({
key: 'Enter',
preventDefault: jest.fn(),
});
expect(onSelectMockFn).toHaveBeenCalledWith('enterKey pressed');
wrapper.vm.onSelect();
});
});
@@ -0,0 +1,69 @@
import ThumbnailGroup from '../ThumbnailGroup.vue';
export default {
title: 'Components/ThumbnailGroup',
component: ThumbnailGroup,
argTypes: {
usersList: {
defaultValue: [
{
name: 'John',
id: 1,
thumbnail: '',
},
{
name: 'John',
id: 2,
thumbnail: '',
},
{
name: 'John',
id: 3,
thumbnail: '',
},
{
name: 'John',
id: 4,
thumbnail: '',
},
{
name: 'John',
id: 5,
thumbnail: '',
},
{
name: 'John',
id: 6,
thumbnail: '',
},
],
control: {
type: 'object',
},
},
size: {
control: {
type: 'text',
},
},
moreThumbnailsText: {
control: {
type: 'text',
default: '2 more',
},
},
showMoreThumbnailsCount: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ThumbnailGroup },
template: '<ThumbnailGroup v-bind="$props"/>',
});
export const Primary = Template.bind({});
+13
View File
@@ -0,0 +1,13 @@
export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management',
AUTOMATIONS: 'automations',
CANNED_RESPONSES: 'canned_responses',
CUSTOM_ATTRIBUTES: 'custom_attributes',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
MACROS: 'macros',
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
};
+4 -2
View File
@@ -6,7 +6,7 @@ export const frontendURL = (path, params) => {
};
const getSSOAccountPath = ({ ssoAccountId, user }) => {
const { accounts = [] } = user || {};
const { accounts = [], account_id = null } = user || {};
const ssoAccount = accounts.find(
account => account.id === Number(ssoAccountId)
);
@@ -14,7 +14,9 @@ const getSSOAccountPath = ({ ssoAccountId, user }) => {
if (ssoAccount) {
accountPath = `accounts/${ssoAccountId}`;
} else if (accounts.length) {
accountPath = `accounts/${accounts[0].id}`;
// If the account id is not found, redirect to the first account
const accountId = account_id || accounts[0].id;
accountPath = `accounts/${accountId}`;
}
return accountPath;
};
@@ -25,6 +25,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.created': this.onNotificationCreated,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
};
}
@@ -67,8 +68,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationRead = data => {
const { contact_last_seen_at: lastSeen } = data;
this.app.$store.dispatch('updateConversationRead', lastSeen);
this.app.$store.dispatch('updateConversation', data);
};
onLogout = () => AuthAPI.logout();
@@ -85,6 +85,11 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;

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