Compare commits

..
Author SHA1 Message Date
Sojan e204fe1c1f Merge branch 'release/2.18.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-06-15 18:44:51 +05:30
Sojan e383e9259b Bump version to 2.18.0 2023-06-15 18:44:13 +05:30
Muhsin KelothandGitHub 2c34dca347 fix: NS_ERROR_STORAGE_BUSY warnings in firefox (#7319)
Fixes: https://linear.app/chatwoot/issue/CW-2075/ns-error-storage-busy-no-error-message
2023-06-15 17:53:26 +05:30
Pranav Raj SandGitHub 69b9123963 fix: Use available_name only (#7312)
We have display_name and the actual name for an agent. display_name is used in all public facing parts. The https://github.com/chatwoot/chatwoot/pull/7232/files PR missed this and used actual name instead.

This PR fixes it.
2023-06-15 17:52:53 +05:30
Shivam MishraandGitHub cac9fe1880 Revert "feat(perf): update query to reduce N+1 impact [CW-1926] (#7228)" (#7271)
This reverts commit f28533bc47.
2023-06-15 16:23:56 +05:30
Nithin David ThomasandGitHub 06ac7c829b fix: Fixes wrong variable usage in transcript file (#7316) 2023-06-15 16:00:21 +05:30
Shivam MishraandGitHub 9985f1c143 refactor: remove redundant taggable declaration (#7310) 2023-06-15 12:57:56 +05:30
Tejaswini ChileandGitHub 1ee6a8fe90 chore: Add validation for processed content field (#7306)
* logging the messages id for message validation exception

* Update the processed_message_content validation over length

* codeclimate

* specs failing for contacts
2023-06-14 18:28:42 +05:30
Muhsin KelothandGitHub 6eb4fa41ff feat: Add sender details and message create date in the attachment API (#7308) 2023-06-14 17:29:13 +05:30
8b91f0ffac fix: Gemfile.lock to reduce vulnerabilities (#7280)
The following vulnerabilities are fixed with an upgrade:
- https://snyk.io/vuln/SNYK-RUBY-RACK-1061917

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-14 15:40:24 +05:30
d2aa19579e feat: Adds support for superscript in help center articles (#7279)
- Adds support for superscript when rendering article markdown
- Chatwoot Markdown Render to render markdown everywhere

Co-authored-by: Sojan <sojan@pepalo.com>
2023-06-14 15:39:00 +05:30
Shivam MishraandGitHub 2e79a32db7 fix: calculation for resolution count (#7293)
* fix: calculation for resolution count

* test: resolution count bug fix

- ensure enqueued jobs are run
- fix the dates check, conversations resolved today should show up in today

* feat: ensure conversations are resolved

* test: do not count extra events if the conversation is not resolved currently

* fix: typo
2023-06-14 13:50:10 +05:30
Shivam MishraandGitHub a86e236d19 feat: update cache headers for cache_keys (#7283)
Update the cache headers for cache_keys to max-age=10, private, stale-while-revalidate=300

1. The cache will be fresh for 10 seconds (max-age=10). During this time, the browser will use the cached version without checking with the server.
2. After the initial 10 seconds, the browser can continue to serve the stale cache for up to 5 minutes (stale-while-revalidate=300). During this period, it will also try to revalidate and update the cache in the background.
3. After 310 seconds in total (10 seconds fresh, 300 seconds stale), if the browser has not been able to revalidate the cache, it will attempt to fetch the fresh resource directly from the server for subsequent requests, causing potential latency equivalent to a network request.

This means that the data will be directly revalidated only every 5 mins. Other times, it will stay fresh for 10 seconds and revalidate in the background. In most cases, we won't have to rely on a cache validation check because there is a WebSocket event for revalidation, so we know if something changes.

Right now the stale-while-revalidate is 5 minutes, we can then move it to 15 minutes.

> The stale-while-revalidate header is not supported in Safari, for Safari the cache keys will only stay in memory for 10 seconds before being marked stale
2023-06-14 13:21:51 +05:30
c755978352 feat: update banner design [CW-1491] (#7296)
* refactor: banner styling

* refactor: update button style for reply assign banner

* feat: remove update-banner classes

---------

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-06-14 12:56:42 +05:30
Tejaswini ChileandGitHub 23ca6d56f9 feat: Contact Exports (#7258) 2023-06-13 09:18:43 +05:30
Shivam MishraandGitHub 429ec7194f feat: add index to reporting events [CW-1960] (#7294)
* fix: annotation

* feat: add better index for reporting_events
2023-06-12 18:23:42 +05:30
Tejaswini ChileandGitHub c99d9f9557 fix: search for nil in-reply-to messages (#7286) 2023-06-12 16:01:56 +05:30
Vishnu NarayananandGitHub 9ff5978d6e feat: Add audit trail events for team (#7285) 2023-06-09 17:30:36 -07:00
Muhsin KelothandGitHub cd28f401ba fix: Custom attribute date is not working in some time zone (#7224) 2023-06-09 16:55:03 -07:00
Tejaswini ChileandGitHub 879a244f93 fix: Automations condition based quoted text (#7272) 2023-06-09 17:00:05 +05:30
Tejaswini ChileandGitHub f64f2138db fix: Update mail check for html_part (#7273) 2023-06-09 16:08:40 +05:30
Sojan JoseandGitHub 48f2e58e59 feat: Ability to update avatars from super admin (#7264)
- Ability to update user avatars from super admin
- Ability to update bot avatars from super admin

fixes: #7060
2023-06-09 15:32:24 +05:30
c715e396f0 feat: added input_select type message support for whatsapp (#6886)
- Added input_select message type support for Whatsapp Cloud API and Whatsapp 360dialog.

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-06-09 15:20:45 +05:30
Pranav Raj SandGitHub c8fac08e0b fix: Cannot read properties of undefined (reading 'some') (#7278)
Fixes https://linear.app/chatwoot/issue/CW-2041/typeerror-cannot-read-properties-of-undefined-reading-some
Fixes https://linear.app/chatwoot/issue/CW-2042/typeerror-cannot-read-properties-of-undefined-reading-filter
2023-06-08 20:12:14 -07:00
7fd220c177 feat: Adds the ability to edit saved segments (#7254)
* feat: Ability to edit saved filters

* chore: Adds edit contact segment

* chore: Minor fixes

* fix: code climate

* chore: Minor fixes

* chore: Adds ability to custom view name

* chore: Minor fixes

* chore: Adds spec for helper

* chore: Revert contact filter to split to new PR

* Delete editSegmentMixin.js

* chore: Revert fixes

* Update app/javascript/dashboard/i18n/locale/en/advancedFilters.json

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

* Update app/javascript/dashboard/i18n/locale/en/advancedFilters.json

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

* chore: Moved from mixin to helper for reusing in segments

* Delete editFolderMixin.js

* chore: Fix specs and added new specs

* chore: review comment fixes

* chore: Minor fixes

* fix: Not resetting applied filter

* feat: Adds the ability to edit saved segments

* feat: Adds specs for API part

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 21:25:51 +05:30
Pranav Raj SandGitHub b3b76d00ff fix: Do not use the default value if the out of office message is empty (#7268) 2023-06-08 08:22:10 -07:00
Shivam MishraandGitHub 2f2ae88cba fix: unattended count mismatch in report and list (#7263) 2023-06-08 17:58:13 +05:30
Tejaswini ChileandGitHub 2b7989f52b fix: reply mailer with sender name (#7232) 2023-06-08 17:07:08 +05:30
d7314079c9 feat: Ability to edit saved folders (#7236)
* feat: Ability to edit saved filters

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 14:58:57 +05:30
1017903ee1 fix: Adds domain option to user cookies set by SDK [CW-352] (#7070)
* fix: Adds domain option to user cookies set by SDK

* Adds domain to init event from chatwootSettings variable

* Testing multiple domains on heroku

* Updates with sdk from staging

* Removes sdk init code

* Testing why cookie is not getting set

* Cleans up testing code

* Refactors code to fix codeclimate issues

* Update app/javascript/sdk/cookieHelpers.js

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

* Adds test cases for setCookieWithDomain

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-06-08 14:27:49 +05:30
Tejaswini ChileandGitHub d25e7fd54e fix: add email logging for source-id email-id and message-id (#7262)
* fix: mail logger for source-id email-id and message-id

* added mail provided to the tracking

* change the logger verb from info to error

* app/jobs/inboxes/fetch_imap_emails_job.rb

* codeclimate fix

* remove extra filter

* fixes: specs
2023-06-07 18:24:37 +05:30
Sojan JoseandGitHub 7e89e3a34a chore: Ensure template_sync timestamp is updated (#7265)
Fix for the cases where there are multiple channels with invalid provider config, which results in the templates sync scheduler failing to schedule jobs for valid channels.

fixes: https://linear.app/chatwoot/issue/CW-2032/bug-whatsapp-template-sync-failing-in-cloud
2023-06-07 17:18:24 +05:30
Pranav Raj SandGitHub 39eaed7766 fix: Cannot read properties of undefined (reading 'always_play_audio_alert') (#7260) 2023-06-06 15:16:25 -07:00
Pranav Raj SandGitHub b1a4907ede fix: around_action not yielding is params are present (#7259) 2023-06-06 14:55:08 -07:00
a01d81a7e1 feat: Update display text for auditlog entries (#7226)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-06-05 12:51:45 -07:00
Sojan JoseandGitHub 117d5301b4 chore: API documentation updates for CSAT surveys (#7255)
- Expose conversation uuid in APIs
- swagger documentation for cast survey update via public/message/update endpoints
- swagger documentation for survey/responses/conversation_uuid endpoint

Fixes: #6328
2023-06-05 21:14:01 +05:30
Tejaswini ChileandGitHub aae6081d73 fix: check content disposition, for inline messages in mail (#7231) 2023-06-05 20:28:32 +05:30
Sojan JoseandGitHub d03924b846 feat: Enable sending template messages in webhooks (#7252)
fixes: https://github.com/chatwoot/chatwoot/issues/5291
2023-06-05 20:27:28 +05:30
b333d0c986 feat: Attachments view (#7156)
* feat: Attachments view with key shortcuts and dynamically updates when user delete or sent new attachments

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-06-05 19:21:47 +05:30
Shivam MishraandGitHub 9f3d155822 feat: Add size limit action (#7253)
* chore: update browserlist

* feat: add size limit action

* feat: set limits

* feat: add size limit in CI

* chore: remove railsenv

* chore: update limits
2023-06-05 18:56:15 +05:30
TOMMYandGitHub 9203b097d2 chore: Show chatwoot_edition in Instance details (#7200)
- Displays the chatwoot edition in the instance details
2023-06-05 17:32:21 +05:30
Marcel StörandGitHub 12cb97db5f fix: Quote database env variables (#7216)
Database credentials, and passwords, in particular, can easily contain characters that are reserved in YAML and must be quoted. 
Example: a case with a password starting with ']'.
2023-06-05 14:19:35 +05:30
Tejaswini ChileandGitHub 09971fd613 fix: Wrap references string into array (#7243) 2023-06-03 07:33:36 +05:30
Pranav Raj SandGitHub ca9d108741 fix: Update documentation of filter APIs (#7245) 2023-06-02 15:36:41 -07:00
Shivam MishraandGitHub f28533bc47 feat(perf): update query to reduce N+1 impact [CW-1926] (#7228)
* feat: update query to reduce N+1 impact

* feat: remove channel eager loading

fixes the error: ActiveRecord::EagerLoadPolymorphicError Exception: Cannot eagerly load the polymorphic association :channel

* test: fix search test

* chore: revert change
2023-06-02 11:11:55 +05:30
Pranav Raj SandGitHub 68f82c11a5 Revert "fix: Updates editor to fix the extra slash issue [cw-1505][cw-1526] (#7091)" (#7238)
This reverts commit 1d0930ef63.
2023-06-01 13:26:02 -07:00
Tejaswini ChileandGitHub abc27fa791 fix: find mail message by references (#7220) 2023-05-31 19:23:29 +05:30
Sojan JoseandGitHub d93a8d05bc chore: Increase character limit for external url fields (#7230)
- Increase the external url field validation to 2048 characters

fixes: https://github.com/chatwoot/chatwoot/issues/7098
2023-05-31 19:17:24 +05:30
Sojan JoseandGitHub 373f5f5b64 chore: Disable coverage reports for .vue files (#7214)
- Disable coverage reporting for .vue files 

fixes: https://linear.app/chatwoot/issue/CW-1331
2023-05-30 19:18:19 +05:30
Sivin VargheseandGitHub ae7df60a75 fix: Special characters not being decoded (#7218) 2023-05-30 18:54:51 +05:30
412d750b6a feat: auditlogs design refactor cw1764 (#7181)
* chore: refactor auditlogs design

* chore: refactor aduit log text

* chore: fix 60% width for activity column

* chore: improve log text formatting

* Apply suggestions from code review

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>

* feat: show agent names if available in auditlogs

* chore: add sign_out

* Apply suggestions from code review

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

* chore: handle custom user actions

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-05-30 14:41:29 +05:30
f1a77ba934 feat: allow superadmins to reset cache keys for IndexedDB (#7180)
Allows super admins to reset the cache for an account. This will force the front end to fetch the data again on the next load.

fixes: https://linear.app/chatwoot/issue/CW-1817

Co-authored-by: Sojan <sojan@pepalo.com>
2023-05-29 23:05:17 +05:30
Sojan JoseandGitHub ffc6364690 chore: Improve Whatsapp Templates Sync (#7210)
- update the templates updated at, even if the API request fails ( to prevent jobs from stacking up in case of API failures upstream )
- sequence the job in batches of 25 requests per minutes schedule ( in case API response time is high, also not to send too many requests in a single batch )
- move the sync job re-rerun to 3 hours ( since we are updating the updated at even in case of failures )(prev 15 minutes )

Fixes: https://linear.app/chatwoot/issue/CW-1590
2023-05-29 22:23:22 +05:30
Sojan JoseandGitHub 3a7633b564 feat: API to create HMAC verified conversations (#7209)
Fixes: #6744
2023-05-29 21:57:24 +05:30
amplitudesandGitHub 91d1d44aa9 fix: profile picture distortion (#7203)
By applying pr-1 on a container div instead of directly on the image (which distorts it), it creates the intended padding effect without any image distortion.
2023-05-29 15:00:14 +05:30
Sojan JoseandGitHub afc97faa8b chore: Rotate pubsub token on password change (#7194)
Fixes: https://linear.app/chatwoot/issue/CW-1350/
2023-05-27 13:24:31 +05:30
1d0930ef63 fix: Updates editor to fix the extra slash issue [cw-1505][cw-1526] (#7091)
* fix: Updates editor to fix the extra slash issue

* Updates yarn package

* Updates yarn package for editor schema

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-05-26 00:54:37 +05:30
Tejaswini ChileandGitHub 776ea6d39a fix: Help center api doc (#7189) 2023-05-25 18:50:00 +05:30
Muhsin KelothandGitHub 2c85098698 fix: Mentions in assigned/participation notification(#7185) 2023-05-25 18:18:56 +05:30
Sivin VargheseandGitHub 6bd0e074dc feat: Sort agents on availability status (#7174) 2023-05-25 14:49:56 +05:30
Vishnu NarayananandGitHub 123fc73394 feat: add audit trail for sign_in and sign_out (#7158)
* feat: add audit_trail for sign_in event

* chore: ignore unrelated User model columns for auditing

* chore: fix prepend call for webhook/automation rule

* chore: add spec for sign_in event

* chore: refactor sign_in auditlog method to enterprise namespace

* feat: add sign_out audit trail

* feat: review comments
2023-05-25 14:27:30 +05:30
88cef88d80 fix: Instagram story with video not rendering (#7184)
* fix: instagram story with video not rendering

* chore: Minor fixes

---------

Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
2023-05-25 12:20:42 +05:30
Tejaswini ChileandGitHub 8e79cf72e1 fix: issue with slack job (#7179) 2023-05-24 20:22:34 +05:30
Sojan JoseandGitHub bfaca851f1 chore: Throttle conversation transcript endpoints (#7155)
- Throttle conversation transcripts to 20 per hour via rack attack

Fixes: https://linear.app/chatwoot/issue/CW-1630/throttle-transcript-endpoint
2023-05-24 19:56:23 +05:30
Muhsin KelothandGitHub 5618b5ebd3 fix: Enable ogg format for audio recording in API channel (#7178) 2023-05-24 15:05:31 +05:30
Muhsin KelothandGitHub 4807052c37 doc: Swagger for conversation priority API (#7172) 2023-05-23 20:39:15 +05:30
Shivam MishraandGitHub 44b1047b90 feat(perf): contacts query performance (#7175)
* feat: use more indexable where condition

* feat: add index concurrently

* chore: update schema

* refactor: update index name
2023-05-23 17:22:47 +05:30
Shivam MishraandGitHub 9c6c19c3e5 fix: CSAT filter metrics rendering & conversation reports not working [CW-1840, CW-1818] (#7170)
* fix: emoji rendering for CSAT

* feat: add tests for CSAT Metrics

* fix: allow rating in metrics

* refactor: hide satisfaction score & total response chart if rating filter is enabled

* refactor: optional chaining in group by

* fix: spacing using autofill

* test: update csat metrics tests

* test: CSAT metric card
2023-05-23 16:47:04 +05:30
Nithin David ThomasandGitHub 2764338453 fix: Fixes wrong copy being sent to users via email invite [cw-1290] (#7159)
* fix: Fixes invite email copy

* fix: Fixes wrong copy being sent to users via email invite
2023-05-23 09:37:40 +05:30
Shivam MishraandGitHub 4f0fcfcbcd feat: allow Microsoft Clarity from CDN (#7163)
* feat: add microsoft clarity to main app

* feat: mask messages

* fix: spacing
2023-05-23 08:00:01 +05:30
Pranav Raj SandGitHub fdbaf98c3b fix: Update breaking specs (#7169) 2023-05-22 17:01:27 -07:00
03bbd048a7 chore: Add action to set dark mode from the react-native-widget (#7167)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-22 15:34:09 -07:00
Tejaswini ChileandGitHub d481b9fbcf feat: Limit the number of custom filters per user (#7101) 2023-05-22 18:03:15 +05:30
Vishnu NarayananandGitHub 68dedc37ba chore: update auditlog timestamp format (#7128) 2023-05-22 17:08:50 +05:30
Shivam MishraandGitHub 958c0d7946 feat(perf): improve performance of conversation filter API [CW-1605] (#7120)
* feat(perf): don't use count

* feat(perf): include messages in the conversation query

* feat: include contact inboxes

* refactor: use blank?
2023-05-22 16:03:11 +05:30
Tejaswini ChileandGitHub 9553329092 chore: find instagram_direct_message query (#7157) 2023-05-22 16:01:31 +05:30
Nithin David ThomasandGitHub d01f2063d0 feat: Creates component to show articles search results [CW-1451] (#7126)
* feat: Creates component to show articles search results

* Adds story for the component
2023-05-22 15:15:19 +05:30
Tejaswini ChileandGitHub 590e4e9c1c Fix: prevent IMAPBadResponse exception from sending the authorization mail (#7154) 2023-05-22 13:51:56 +05:30
Tejaswini ChileandGitHub 0e903d2365 chore: Add delay for slack job when message has attachments (#7107) 2023-05-22 13:51:14 +05:30
Shivam MishraandGitHub d6ce1ceeeb fix: CSAT chart showing incorrect data (#7152)
* fix: computation of data for csat chart

* fix: make horizontal chart reactive
2023-05-22 11:01:01 +05:30
Sivin VargheseandGitHub e8f56d0e56 chore: Update greeting message label in settings (#7088) 2023-05-21 21:07:37 -07:00
Pranav Raj SandGitHub b8dae07c0f feat: Add an option to use darkMode only in the widget (#7151) 2023-05-22 09:05:05 +05:30
Sojan JoseandGitHub e3f4be97c0 fix: Reauthorize when channel settings updated (#7134)
Fixes: https://linear.app/chatwoot/issue/CW-1803/bug-inbox-doesnt-update-reauthorisation-required-automattically
2023-05-19 22:04:34 +05:30
Sojan 1f4d096804 Merge branch 'hotfix/2.17.1' into develop 2023-05-19 20:56:16 +05:30
Sojan 08fa370261 Merge branch 'hotfix/2.17.1'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-05-19 20:56:06 +05:30
Sivin VargheseandGitHub 1bcc438b21 fix: Problem when using Brazilian Portuguese (#7135)
Fixes:  #7127
2023-05-19 20:55:01 +05:30
Vishnu NarayananandGitHub 8b3d13a920 fix: add libvips to Dockerfile for rails7 (#7133)
Fixes: #7124
Fixes: https://linear.app/chatwoot/issue/CW-1805/investigate-217-upgrade-issues-for-dockerk8s-installations
2023-05-19 17:59:33 +05:30
Sojan dcdd58bae5 Bump version to 2.17.1 2023-05-19 16:00:27 +05:30
Sojan de1fb6ba4e Merge branch 'release/2.17.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-05-16 12:33:47 +05:30
Pranav Raj S 18c5c47836 Merge branch 'hotfix/2.16.1' 2023-05-02 15:53:33 -07:00
253 changed files with 12008 additions and 788 deletions
+1 -1
View File
@@ -157,7 +157,7 @@ jobs:
- run:
name: Code Climate Test Coverage
command: |
~/tmp/cc-test-reporter format-coverage -t lcov -o coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json buildreports/lcov.info
~/tmp/cc-test-reporter format-coverage -t lcov -o "coverage/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
- persist_to_workspace:
root: coverage
+3
View File
@@ -173,6 +173,9 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
## LogRocket
# LOG_ROCKET_PROJECT_ID=xxxxx/some-project
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
+38
View File
@@ -0,0 +1,38 @@
name: Run Size Limit Check
on:
pull_request:
branches:
- develop
jobs:
test:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- uses: actions/setup-node@v3
with:
node-version: 16
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Run asset compile
run: bundle exec rake assets:precompile
- name: Size Check
run: yarn run size
+1
View File
@@ -75,6 +75,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate'
gem 'administrate-field-active_storage'
##--- gems for pubsub service ---##
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
+74 -69
View File
@@ -33,70 +33,70 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
actioncable (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.4.3)
actionpack (= 7.0.4.3)
activejob (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
actionmailbox (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.4.3)
actionpack (= 7.0.4.3)
actionview (= 7.0.4.3)
activejob (= 7.0.4.3)
activesupport (= 7.0.4.3)
actionmailer (7.0.5)
actionpack (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activesupport (= 7.0.5)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.4.3)
actionview (= 7.0.4.3)
activesupport (= 7.0.4.3)
rack (~> 2.0, >= 2.2.0)
actionpack (7.0.5)
actionview (= 7.0.5)
activesupport (= 7.0.5)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.4.3)
actionpack (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
actiontext (7.0.5)
actionpack (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.4.3)
activesupport (= 7.0.4.3)
actionview (7.0.5)
activesupport (= 7.0.5)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.0.4.3)
activesupport (= 7.0.4.3)
activejob (7.0.5)
activesupport (= 7.0.5)
globalid (>= 0.3.6)
activemodel (7.0.4.3)
activesupport (= 7.0.4.3)
activerecord (7.0.4.3)
activemodel (= 7.0.4.3)
activesupport (= 7.0.4.3)
activemodel (7.0.5)
activesupport (= 7.0.5)
activerecord (7.0.5)
activemodel (= 7.0.5)
activesupport (= 7.0.5)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.4.3)
actionpack (= 7.0.4.3)
activejob (= 7.0.4.3)
activerecord (= 7.0.4.3)
activesupport (= 7.0.4.3)
activestorage (7.0.5)
actionpack (= 7.0.5)
activejob (= 7.0.5)
activerecord (= 7.0.5)
activesupport (= 7.0.5)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.4.3)
activesupport (7.0.5)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -113,6 +113,9 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate (>= 0.2.2)
rails (>= 7.0)
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
@@ -353,7 +356,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.13.0)
i18n (1.14.1)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -420,9 +423,9 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.20.0)
loofah (2.21.3)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
nokogiri (>= 1.12.0)
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
@@ -462,14 +465,14 @@ GEM
sidekiq
newrelic_rpm (8.16.0)
nio4r (2.5.9)
nokogiri (1.14.3)
mini_portile2 (~> 2.8.0)
nokogiri (1.15.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.14.3-arm64-darwin)
nokogiri (1.15.2-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-darwin)
nokogiri (1.15.2-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.3-x86_64-linux)
nokogiri (1.15.2-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -522,7 +525,7 @@ GEM
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.6.2)
racc (1.7.0)
rack (2.2.7)
rack-attack (6.6.1)
rack (>= 1.0, < 3)
@@ -537,28 +540,29 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.4.3)
actioncable (= 7.0.4.3)
actionmailbox (= 7.0.4.3)
actionmailer (= 7.0.4.3)
actionpack (= 7.0.4.3)
actiontext (= 7.0.4.3)
actionview (= 7.0.4.3)
activejob (= 7.0.4.3)
activemodel (= 7.0.4.3)
activerecord (= 7.0.4.3)
activestorage (= 7.0.4.3)
activesupport (= 7.0.4.3)
rails (7.0.5)
actioncable (= 7.0.5)
actionmailbox (= 7.0.5)
actionmailer (= 7.0.5)
actionpack (= 7.0.5)
actiontext (= 7.0.5)
actionview (= 7.0.5)
activejob (= 7.0.5)
activemodel (= 7.0.5)
activerecord (= 7.0.5)
activestorage (= 7.0.5)
activesupport (= 7.0.5)
bundler (>= 1.15.0)
railties (= 7.0.4.3)
railties (= 7.0.5)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0)
loofah (~> 2.19, >= 2.19.1)
railties (7.0.4.3)
actionpack (= 7.0.4.3)
activesupport (= 7.0.4.3)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.5)
actionpack (= 7.0.5)
activesupport (= 7.0.5)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -722,8 +726,8 @@ GEM
stripe (8.5.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.2.1)
tilt (2.1.0)
thor (1.2.2)
tilt (2.2.0)
time_diff (0.3.0)
activesupport
i18n
@@ -784,8 +788,8 @@ GEM
PLATFORMS
arm64-darwin-20
arm64-darwin-22
arm64-darwin-21
arm64-darwin-22
ruby
x86_64-darwin-18
x86_64-darwin-20
@@ -798,6 +802,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate
administrate-field-active_storage
annotate
attr_extras
audited (~> 5.3)
@@ -916,4 +921,4 @@ RUBY VERSION
ruby 3.2.2p185
BUNDLED WITH
2.4.10
2.4.6
+1
View File
@@ -1,5 +1,6 @@
//= link_tree ../images
//= link administrate/application.css
//= link administrate/application.js
//= link administrate-field-active_storage/application.css
//= link dashboardChart.js
//= link secretField.js
@@ -63,9 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.where(
@conversation ||= Conversation.where(conversation_params).find_by(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
).find_by(conversation_params) || build_conversation
) || build_conversation
end
def message_content
@@ -96,6 +96,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
+1 -2
View File
@@ -96,10 +96,9 @@ class V2::ReportBuilder
def conversations
@open_conversations = scope.conversations.where(account_id: @account.id).open
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
open: @open_conversations.count,
unattended: @open_conversations.count - first_response_count
unattended: @open_conversations.unattended.count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
metric
@@ -1,11 +1,13 @@
class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts::Contacts::BaseController
include HmacConcern
before_action :ensure_inbox, only: [:create]
def create
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
@@ -42,6 +42,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
head :ok
end
def export
column_names = params['column_names']
Account::ContactsExportJob.perform_later(Current.account.id, column_names)
head :ok, message: I18n.t('errors.contacts.export.success')
end
# returns online contacts
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
@@ -1,6 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types
include DateRangeHelper
include HmacConcern
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :inbox, :contact, :contact_inbox, only: [:create]
@@ -104,9 +105,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def set_conversation_status
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
# commenting this out to see if there are any errors, if not we can remove this in subsequent releases
# status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
@@ -152,7 +150,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
source_id: params[:source_id],
hmac_verified: hmac_verified?
).perform
end
@@ -11,6 +11,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
@custom_filter = current_user.custom_filters.create!(
permitted_payload.merge(account_id: Current.account.id)
)
render json: { error: @custom_filter.errors.messages }, status: :unprocessable_entity and return unless @custom_filter.valid?
end
def update
@@ -44,26 +44,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
def update
@inbox.update!(permitted_params.except(:channel))
update_inbox_working_hours
channel_attributes = get_channel_attributes(@inbox.channel_type)
# Inbox update doesn't necessarily need channel attributes
return if permitted_params(channel_attributes)[:channel].blank?
if @inbox.inbox_type == 'Email'
begin
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
@inbox.channel.reauthorized!
end
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
update_channel if channel_update_required?
end
def agent_bot
@@ -103,6 +84,35 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
def update_channel
channel_attributes = get_channel_attributes(@inbox.channel_type)
return if permitted_params(channel_attributes)[:channel].blank?
validate_and_update_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
reauthorize_and_update_channel(channel_attributes)
update_channel_feature_flags
end
def channel_update_required?
permitted_params(get_channel_attributes(@inbox.channel_type))[:channel].present?
end
def validate_and_update_email_channel(channel_attributes)
validate_email_channel(channel_attributes)
rescue StandardError => e
render json: { message: e }, status: :unprocessable_entity and return
end
def reauthorize_and_update_channel(channel_attributes)
@inbox.channel.reauthorized! if @inbox.channel.respond_to?(:reauthorized!)
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
end
def update_channel_feature_flags
return unless @inbox.web_widget?
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
@@ -37,6 +37,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def cache_keys
expires_in 10.seconds, public: false, stale_while_revalidate: 5.minutes
render json: { cache_keys: get_cache_keys }, status: :ok
end
+5
View File
@@ -0,0 +1,5 @@
module HmacConcern
def hmac_verified?
ActiveModel::Type::Boolean.new.cast(params[:hmac_verified]).present?
end
end
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -43,8 +43,6 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
end
def render_article_content(content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(content).html_safe
# rubocop:enable Rails/OutputSafety
ChatwootMarkdownRenderer.new(content).render_article
end
end
@@ -6,6 +6,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
def set_locale(&)
switch_locale_with_portal(&) if params[:locale].present?
switch_locale_with_article(&) if params[:article_slug].present?
yield
end
def switch_locale_with_portal(&)
@@ -50,6 +50,13 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
# rubocop:enable Rails/I18nLocaleTexts
end
def reset_cache
requested_resource.reset_cache_keys
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Cache keys cleared')
# rubocop:enable Rails/I18nLocaleTexts
end
def destroy
account = Account.find(params[:id])
@@ -41,4 +41,14 @@ class SuperAdmin::AgentBotsController < SuperAdmin::ApplicationController
# See https://administrate-prototype.herokuapp.com/customizing_controller_actions
# for more information
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_agent_bots_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
end
@@ -5,6 +5,17 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
sha
postgres_status
redis_metrics
chatwoot_edition
end
def chatwoot_edition
@metrics['Chatwoot edition'] = if ChatwootApp.enterprise?
'Enterprise'
elsif ChatwootApp.custom?
'Custom'
else
'Community'
end
end
def chatwoot_version
@@ -45,6 +45,17 @@ class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# empty values into nil values. It uses other APIs such as `resource_class`
# and `dashboard`:
#
def destroy_avatar
avatar = requested_resource.avatar
avatar.purge
redirect_back(fallback_location: super_admin_users_path)
end
def scoped_resource
resource_class.with_attached_avatar
end
def resource_params
permitted_params = super
permitted_params.delete(:password) if permitted_params[:password].blank?
+7
View File
@@ -10,6 +10,11 @@ class AgentBotDashboard < Administrate::BaseDashboard
ATTRIBUTE_TYPES = {
access_token: Field::HasOne,
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_agent_bot, { attachment_id: attachment.id }]
end
),
id: Field::Number,
name: Field::String,
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
@@ -36,6 +41,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
id
avatar_url
account
name
description
@@ -47,6 +53,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
avatar
account
description
outgoing_url
+6
View File
@@ -11,6 +11,11 @@ class UserDashboard < Administrate::BaseDashboard
account_users: Field::HasMany,
id: Field::Number,
avatar_url: AvatarField,
avatar: Field::ActiveStorage.with_options(
destroy_url: proc do |_namespace, _resource, attachment|
[:avatar_super_admin_user, { attachment_id: attachment.id }]
end
),
provider: Field::String,
uid: Field::String,
password: Field::Password,
@@ -69,6 +74,7 @@ class UserDashboard < Administrate::BaseDashboard
# on the model's form (`new` and `edit`) pages.
FORM_ATTRIBUTES = %i[
name
avatar
display_name
email
password
+1 -1
View File
@@ -102,7 +102,7 @@ class ConversationFinder
when 'participating'
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.where(first_reply_created_at: nil)
@conversations = @conversations.unattended
end
@conversations
end
+1 -3
View File
@@ -7,8 +7,6 @@ module MessageFormatHelper
end
def render_message_content(message_content)
# rubocop:disable Rails/OutputSafety
CommonMarker.render_html(message_content).html_safe
# rubocop:enable Rails/OutputSafety
ChatwootMarkdownRenderer.new(message_content).render_message
end
end
+3 -1
View File
@@ -29,7 +29,9 @@ module ReportHelper
end
def resolutions_count
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
object_scope = scope.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_resolved,
conversations: { status: :resolved }).distinct
(get_grouped_values object_scope).count
end
def avg_first_response_time
-5
View File
@@ -115,11 +115,6 @@ export default {
<style lang="scss">
@import './assets/scss/app';
.update-banner {
height: var(--space-larger);
align-items: center;
font-size: var(--font-size-small) !important;
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
+4
View File
@@ -75,6 +75,10 @@ class ContactAPI extends ApiClient {
destroyAvatar(contactId) {
return axios.delete(`${this.url}/${contactId}/avatar`);
}
exportContacts() {
return axios.get(`${this.url}/export`);
}
}
export default new ContactAPI();
+2 -2
View File
@@ -35,10 +35,10 @@ class CSATReportsAPI extends ApiClient {
});
}
getMetrics({ from, to, user_ids, inbox_id, team_id } = {}) {
getMetrics({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
// no ratings for metrics
return axios.get(`${this.url}/metrics`, {
params: { since: from, until: to, user_ids, inbox_id, team_id },
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
});
}
}
@@ -127,6 +127,10 @@ class ConversationApi extends ApiClient {
user_ids: userIds,
});
}
getAllAttachments(conversationId) {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
}
export default new ConversationApi();
@@ -210,5 +210,12 @@ describe('#ConversationAPI', () => {
{ params: { page: payload.page } }
);
});
it('#getAllAttachments', () => {
conversationAPI.getAllAttachments(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/attachments'
);
});
});
});
@@ -29,13 +29,13 @@
}
.modal-image {
max-height: 90%;
max-width: 90%;
max-height: 80vh;
max-width: 80vw;
}
.modal-video {
max-height: 75vh;
max-width: 100%;
max-height: 80vh;
max-width: 80vw;
}
&::before {
@@ -53,16 +53,6 @@
width: 100%;
}
}
.video {
.modal-container {
width: auto;
.modal--close {
z-index: var(--z-index-low);
}
}
}
}
.conversations-list-wrap {
@@ -400,4 +390,3 @@
margin-bottom: 0;
}
}
@@ -47,6 +47,14 @@
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
@@ -168,8 +176,11 @@
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal"
:is-folder-view="hasActiveFolders"
@applyFilter="onApplyFilter"
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
</div>
@@ -193,6 +204,9 @@ import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCust
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import {
hasPressedAltAndJKey,
@@ -289,6 +303,11 @@ export default {
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
agentList: 'agents/getAgents',
teamsList: 'teams/getTeams',
inboxesList: 'inboxes/getInboxes',
campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels',
}),
hasAppliedFilters() {
return this.appliedFilters.length !== 0;
@@ -451,6 +470,9 @@ export default {
}
return undefined;
},
activeFolderName() {
return this.activeFolder?.name;
},
activeTeam() {
if (this.teamId) {
return this.$store.getters['teams/getTeam'](this.teamId);
@@ -483,9 +505,7 @@ export default {
this.resetAndFetchData();
},
activeFolder() {
if (!this.hasAppliedFilters) {
this.resetAndFetchData();
}
this.resetAndFetchData();
},
chatLists() {
this.chatsOnView = this.conversationList;
@@ -496,6 +516,10 @@ export default {
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
this.resetAndFetchData();
if (this.hasActiveFolders) {
this.$store.dispatch('campaigns/get');
}
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
@@ -508,6 +532,15 @@ export default {
this.$store.dispatch('emptyAllConversations');
this.fetchFilteredConversations(payload);
},
onUpdateSavedFilter(payload, folderName) {
const payloadData = {
...this.activeFolder,
name: folderName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
},
@@ -521,15 +554,70 @@ export default {
this.showDeleteFoldersModal = false;
},
onToggleAdvanceFiltersModal() {
if (!this.hasAppliedFilters) {
if (!this.hasAppliedFilters && !this.hasActiveFolders) {
this.initializeExistingFilterToModal();
}
if (this.hasActiveFolders) {
this.initializeFolderToFilterModal(this.activeFolder);
}
this.showAdvancedFilters = true;
},
closeAdvanceFiltersModal() {
this.showAdvancedFilters = false;
this.appliedFilter = [];
},
setParamsForEditFolderModal() {
// Here we are setting the params for edit folder modal to show the existing values.
// For agent, team, inboxes,and campaigns we get only the id's from the query.
// So we are mapping the id's to the actual values.
// For labels we get the name of the label from the query.
// If we delete the label from the label list then we will not be able to show the label name.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit folder modal.
const params = {
agents: this.agentList,
teams: this.teamsList,
inboxes: this.inboxesList,
labels: this.labels,
campaigns: this.campaigns,
languages: languages,
countries: countries,
filterTypes: advancedFilterTypes,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('conversation_attribute'),
};
return params;
},
initializeFolderToFilterModal(activeFolder) {
// Here we are setting the params for edit folder modal.
// To show the existing values. when we click on edit folder button.
// Here we get the query from the active folder.
// And we are mapping the query to the actual values.
// To show in the edit folder modal by the help of generateValuesForEditCustomViews helper.
const query = activeFolder?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditFolderModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
@@ -575,6 +663,7 @@ export default {
}
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
this.$store.dispatch('conversationPage/reset');
this.$store.dispatch('emptyAllConversations');
@@ -587,7 +676,6 @@ export default {
return;
}
this.fetchConversations();
this.appliedFilter = [];
},
fetchConversations() {
this.$store
@@ -115,7 +115,7 @@
</template>
<script>
import format from 'date-fns/format';
import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
@@ -143,15 +143,20 @@ export default {
},
computed: {
formattedValue() {
displayValue() {
if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT);
return new Date(this.value || new Date()).toLocaleDateString();
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
return this.value;
},
formattedValue() {
return this.isAttributeTypeDate
? format(this.value ? new Date(this.value) : new Date(), DATE_FORMAT)
: this.value;
},
listOptions() {
return this.values.map((value, index) => ({
id: index + 1,
@@ -192,17 +197,11 @@ export default {
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
displayValue() {
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
}
return this.editedValue;
},
},
watch: {
value() {
this.isEditing = false;
this.editedValue = this.value;
this.editedValue = this.formattedValue;
},
},
@@ -249,9 +248,8 @@ export default {
onUpdate() {
const updatedValue =
this.attributeType === 'date'
? format(new Date(this.editedValue), DATE_FORMAT)
? parseISO(this.editedValue)
: this.editedValue;
this.$v.$touch();
if (this.$v.$invalid) {
return;
@@ -1,7 +1,6 @@
<template>
<banner
v-if="shouldShowBanner"
class="update-banner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
@@ -11,26 +11,29 @@
{{ hrefLinkText }}
</a>
</span>
<woot-button
v-if="hasActionButton"
size="small"
variant="link"
icon="arrow-right"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="small"
variant="link"
color-scheme="secondary"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
/>
<div class="actions">
<woot-button
v-if="hasActionButton"
size="tiny"
icon="arrow-right"
:variant="actionButtonVariant"
color-scheme="primary"
class-names="banner-action__button"
@click="onClick"
>
{{ actionButtonLabel }}
</woot-button>
<woot-button
v-if="hasCloseButton"
size="tiny"
:color-scheme="colorScheme"
icon="dismiss-circle"
class-names="banner-action__button"
@click="onClickClose"
>
{{ $t('GENERAL_SETTINGS.DISMISS') }}
</woot-button>
</div>
</div>
</template>
@@ -53,6 +56,10 @@ export default {
type: Boolean,
default: false,
},
actionButtonVariant: {
type: String,
default: '',
},
actionButtonLabel: {
type: String,
default: '',
@@ -68,7 +75,12 @@ export default {
},
computed: {
bannerClasses() {
return [this.colorScheme];
const classList = [this.colorScheme, `banner-align-${this.align}`];
if (this.hasActionButton || this.hasCloseButton) {
classList.push('has-button');
}
return classList;
},
},
methods: {
@@ -84,17 +96,26 @@ export default {
<style lang="scss" scoped>
.banner {
--x-padding: var(--space-normal);
--y-padding: var(--space-slab);
display: flex;
gap: var(--x-padding);
color: var(--white);
font-size: var(--font-size-mini);
padding: var(--space-slab) var(--space-normal);
padding: var(--y-padding) var(--x-padding);
justify-content: center;
position: sticky;
&.primary {
background: var(--w-500);
.banner-action__button {
background: var(--w-600);
border: none;
color: var(--white);
&:hover {
background: var(--w-800);
}
}
}
@@ -107,7 +128,16 @@ export default {
}
&.alert {
background: var(--r-400);
background: var(--r-500);
.banner-action__button {
background: var(--r-700);
border: none;
color: var(--white);
&:hover {
background: var(--r-800);
}
}
}
&.warning {
@@ -133,8 +163,6 @@ export default {
}
.banner-action__button {
margin: 0 var(--space-smaller);
::v-deep .button__content {
white-space: nowrap;
}
@@ -144,5 +172,11 @@ export default {
display: flex;
align-items: center;
}
.actions {
display: flex;
gap: var(--space-smaller);
right: var(--y-padding);
}
}
</style>
@@ -45,6 +45,14 @@ export default {
default: () => {},
},
},
watch: {
collection() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
},
mounted() {
this.renderChart(this.collection, {
...chartOptions,
@@ -1,9 +1,21 @@
<template>
<div class="column">
<woot-modal-header :header-title="$t('FILTER.TITLE')">
<p>{{ $t('FILTER.SUBTITLE') }}</p>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ filterModalSubTitle }}</p>
</woot-modal-header>
<div class="row modal-content">
<div class="column modal-content">
<div v-if="isFolderView" class="columns">
<label class="input-label" :class="{ error: !activeFolderNewName }">
{{ $t('FILTER.FOLDER_LABEL') }}
<input v-model="activeFolderNewName" type="text" class="name-input" />
<span v-if="!activeFolderNewName" class="message">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap">
<filter-input-box
v-for="(filter, i) in appliedFilters"
@@ -42,7 +54,14 @@
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button @click="submitFilterQuery">
<woot-button
v-if="isFolderView"
:disabled="!activeFolderNewName"
@click="updateSavedCustomViews"
>
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
@@ -81,6 +100,14 @@ export default {
type: Array,
default: () => [],
},
activeFolderName: {
type: String,
default: '',
},
isFolderView: {
type: Boolean,
default: false,
},
},
validations: {
appliedFilters: {
@@ -107,6 +134,7 @@ export default {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeFolderNewName: this.activeFolderName,
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
@@ -119,6 +147,16 @@ export default {
...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters',
}),
filterModalHeaderTitle() {
return !this.isFolderView
? this.$t('FILTER.TITLE')
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
},
filterModalSubTitle() {
return !this.isFolderView
? this.$t('FILTER.SUBTITLE')
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
this.setFilterAttributes();
@@ -126,7 +164,7 @@ export default {
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters];
} else {
} else if (!this.isFolderView) {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
@@ -177,11 +215,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators;
return type?.filterOperators;
},
getDropdownValues(type) {
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
@@ -267,11 +305,30 @@ export default {
}
},
appendNewFilter() {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
if (this.isFolderView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
@@ -296,6 +353,9 @@ export default {
})),
});
},
updateSavedCustomViews() {
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
@@ -322,4 +382,12 @@ export default {
.filter-actions {
margin-top: var(--space-normal);
}
.input-label {
margin-bottom: var(--space-smaller);
.name-input {
width: 50%;
}
}
</style>
@@ -25,10 +25,14 @@
<blockquote v-if="storyReply" class="story-reply-quote">
<span>{{ $t('CONVERSATION.REPLIED_TO_STORY') }}</span>
<bubble-image
v-if="!hasStoryError"
v-if="!hasImgStoryError && storyUrl"
:url="storyUrl"
@error="onStoryLoadError"
/>
<bubble-video
v-else-if="hasImgStoryError && storyUrl"
:url="storyUrl"
/>
</blockquote>
<bubble-text
v-if="data.content"
@@ -49,22 +53,11 @@
</span>
<div v-if="!isPending && hasAttachments">
<div v-for="attachment in data.attachments" :key="attachment.id">
<bubble-image
v-if="attachment.file_type === 'image' && !hasImageError"
:url="attachment.data_url"
<bubble-image-audio-video
v-if="isAttachmentImageVideoAudio(attachment.file_type)"
:attachment="attachment"
@error="onImageLoadError"
/>
<audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
:url="attachment.data_url"
/>
<bubble-location
v-else-if="attachment.file_type === 'location'"
:latitude="attachment.coordinates_lat"
@@ -140,11 +133,12 @@ import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions';
import BubbleFile from './bubble/File';
import BubbleImage from './bubble/Image';
import BubbleVideo from './bubble/Video';
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
import BubbleIntegration from './bubble/Integration.vue';
import BubbleLocation from './bubble/Location';
import BubbleMailHead from './bubble/MailHead';
import BubbleText from './bubble/Text';
import BubbleVideo from './bubble/Video.vue';
import BubbleContact from './bubble/Contact';
import Spinner from 'shared/components/Spinner';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
@@ -161,11 +155,12 @@ export default {
BubbleActions,
BubbleFile,
BubbleImage,
BubbleVideo,
BubbleImageAudioVideo,
BubbleIntegration,
BubbleLocation,
BubbleMailHead,
BubbleText,
BubbleVideo,
BubbleContact,
ContextMenu,
Spinner,
@@ -200,7 +195,7 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasStoryError: false,
hasImgStoryError: false,
};
},
computed: {
@@ -429,12 +424,12 @@ export default {
watch: {
data() {
this.hasImageError = false;
this.hasStoryError = false;
this.hasImgStoryError = false;
},
},
mounted() {
this.hasImageError = false;
this.hasStoryError = false;
this.hasImgStoryError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
@@ -443,6 +438,9 @@ export default {
clearTimeout(this.higlightTimeout);
},
methods: {
isAttachmentImageVideoAudio(fileType) {
return ['image', 'audio', 'video'].includes(fileType);
},
hasMediaAttachment(type) {
if (this.hasAttachments && this.data.attachments.length > 0) {
const { attachments = [{}] } = this.data;
@@ -464,7 +462,7 @@ export default {
this.hasImageError = true;
},
onStoryLoadError() {
this.hasStoryError = true;
this.hasImgStoryError = true;
},
openContextMenu(e) {
const shouldSkipContextMenu =
@@ -36,6 +36,7 @@
v-for="message in getReadMessages"
:key="message.id"
class="message--read ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -56,6 +57,7 @@
v-for="message in getUnReadMessages"
:key="message.id"
class="message--unread ph-no-capture"
data-clarity-mask="True"
:data="message"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@@ -277,6 +279,7 @@ export default {
if (newChat.id === oldChat.id) {
return;
}
this.fetchAllAttachmentsFromCurrentChat();
this.selectedTweetId = null;
},
},
@@ -288,6 +291,7 @@ export default {
mounted() {
this.addScrollListener();
this.fetchAllAttachmentsFromCurrentChat();
},
beforeDestroy() {
@@ -296,6 +300,9 @@ export default {
},
methods: {
fetchAllAttachmentsFromCurrentChat() {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SET_TWEET_REPLY, this.setSelectedTweet);
@@ -2,6 +2,7 @@
<div class="reply-box" :class="replyBoxClass">
<banner
v-if="showSelfAssignBanner"
action-button-variant="link"
color-scheme="secondary"
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
:has-action-button="true"
@@ -501,7 +502,7 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel) {
if (this.isAWhatsAppChannel || this.isAPIInbox) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -0,0 +1,106 @@
<template>
<div class="message-text__wrap" :class="attachmentTypeClasses">
<img
v-if="isImage && !isImageError"
:src="attachment.data_url"
@click="onClick"
@error="onImgError()"
/>
<video
v-if="isVideo"
:src="attachment.data_url"
muted
playsInline
@click="onClick"
/>
<audio v-else-if="isAudio" controls class="skip-context-menu">
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<gallery-view
v-if="show"
:show.sync="show"
:attachment="attachment"
:all-attachments="filteredCurrentChatAttachments"
@error="onImgError()"
@close="onClose"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
import GalleryView from '../components/GalleryView';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
components: {
GalleryView,
},
props: {
attachment: {
type: Object,
required: true,
},
},
data() {
return {
show: false,
isImageError: false,
};
},
computed: {
...mapGetters({
currentChatAttachments: 'getSelectedChatAttachments',
}),
isImage() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
},
attachmentTypeClasses() {
return {
image: this.isImage,
video: this.isVideo,
};
},
filteredCurrentChatAttachments() {
const attachments = this.currentChatAttachments.filter(attachment =>
['image', 'video', 'audio'].includes(attachment.file_type)
);
return attachments;
},
},
watch: {
attachment() {
this.isImageError = false;
},
},
methods: {
onClose() {
this.show = false;
},
onClick(e) {
if (hasPressedCommand(e)) {
window.open(this.attachment.data_url, '_blank');
return;
}
this.show = true;
},
onImgError() {
this.isImageError = true;
this.$emit('error');
},
},
};
</script>
@@ -0,0 +1,201 @@
<template>
<woot-modal full-width :show.sync="show" :on-close="onClose">
<div v-on-clickaway="onClose" class="gallery-modal--wrap" @click="onClose">
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="secondary"
icon="chevron-left"
:disabled="activeImageIndex === 0"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex - 1],
activeImageIndex - 1
)
"
/>
</div>
<div class="attachments-viewer">
<div class="attachment-view">
<img
v-if="isImage"
:key="attachmentSrc"
:src="attachmentSrc"
class="modal-image skip-context-menu"
@click.stop
/>
<video
v-if="isVideo"
:key="attachmentSrc"
:src="attachmentSrc"
controls
playsInline
class="modal-video skip-context-menu"
@click.stop
/>
<audio
v-if="isAudio"
:key="attachmentSrc"
controls
class="skip-context-menu"
@click.stop
>
<source :src="`${attachmentSrc}?t=${Date.now()}`" />
</audio>
</div>
</div>
<div class="attachment-toggle--button">
<woot-button
v-if="hasMoreThanOneAttachment"
size="large"
variant="smooth"
color-scheme="secondary"
:disabled="activeImageIndex === allAttachments.length - 1"
icon="chevron-right"
@click.stop="
onClickChangeAttachment(
allAttachments[activeImageIndex + 1],
activeImageIndex + 1
)
"
/>
</div>
</div>
</woot-modal>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import {
isEscape,
hasPressedArrowLeftKey,
hasPressedArrowRightKey,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
const ALLOWED_FILE_TYPES = {
IMAGE: 'image',
VIDEO: 'video',
AUDIO: 'audio',
};
export default {
mixins: [eventListenerMixins, clickaway],
props: {
show: {
type: Boolean,
required: true,
},
attachment: {
type: Object,
required: true,
},
allAttachments: {
type: Array,
required: true,
},
},
data() {
return {
attachmentSrc: '',
activeFileType: '',
activeImageIndex:
this.allAttachments.findIndex(
attachment => attachment.id === this.attachment.id
) || 0,
};
},
computed: {
hasMoreThanOneAttachment() {
return this.allAttachments.length > 1;
},
isImage() {
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
},
isVideo() {
return this.activeFileType === ALLOWED_FILE_TYPES.VIDEO;
},
isAudio() {
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
},
methods: {
onClose() {
this.$emit('close');
},
onClickChangeAttachment(attachment, index) {
if (!attachment) {
return;
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
return;
}
this.attachmentSrc = attachment.data_url;
this.activeFileType = type;
},
onKeyDownHandler(e) {
if (isEscape(e)) {
this.onClose();
} else if (hasPressedArrowLeftKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex - 1],
this.activeImageIndex - 1
);
} else if (hasPressedArrowRightKey(e)) {
this.onClickChangeAttachment(
this.allAttachments[this.activeImageIndex + 1],
this.activeImageIndex + 1
);
}
},
},
};
</script>
<style lang="scss" scoped>
.gallery-modal--wrap {
display: flex;
flex-direction: row;
align-items: center;
width: inherit;
height: inherit;
.attachments-viewer {
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
.attachment-view {
display: flex;
align-items: center;
justify-content: center;
img {
margin: 0 auto;
}
video {
margin: 0 auto;
}
}
}
.attachment-toggle--button {
width: var(--space-mega);
min-width: var(--space-mega);
display: flex;
justify-content: center;
}
}
</style>
@@ -79,6 +79,7 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import agentMixin from 'dashboard/mixins/agentMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
export default {
@@ -87,7 +88,7 @@ export default {
MenuItemWithSubmenu,
AgentLoadingPlaceholder,
},
mixins: [snoozeTimesMixin],
mixins: [snoozeTimesMixin, agentMixin],
props: {
status: {
type: String,
@@ -202,6 +203,16 @@ export default {
teams: 'teams/getTeams',
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
filteredAgentOnAvailability() {
const agents = this.$store.getters[
'inboxAssignableAgents/getAssignableAgents'
](this.inboxId);
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const filteredAgents = this.sortedAgentsByAvailability(
agentsByUpdatedPresence
);
return filteredAgents;
},
assignableAgents() {
return [
{
@@ -212,9 +223,7 @@ export default {
account_id: 0,
email: 'None',
},
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
),
...this.filteredAgentOnAvailability,
];
},
},
@@ -246,6 +255,7 @@ export default {
...(type === 'icon' && { icon: option.icon }),
...(type === 'label' && { color: option.color }),
...(type === 'agent' && { thumbnail: option.thumbnail }),
...(type === 'agent' && { status: option.availability_status }),
...(type === 'text' && { label: option.label }),
...(type === 'label' && { label: option.title }),
...(type === 'agent' && { label: option.name }),
@@ -15,6 +15,7 @@
v-if="variant === 'agent'"
:username="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
class="agent-thumbnail"
/>
@@ -0,0 +1,119 @@
export const getInputType = (key, operator, filterTypes) => {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
};
export const generateCustomAttributesInputType = type => {
const filterInputTypes = {
text: 'string',
number: 'string',
date: 'string',
checkbox: 'multi_select',
list: 'multi_select',
link: 'string',
};
return filterInputTypes[type];
};
export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key
);
const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type
);
return filterInputTypes;
};
export const getValuesName = (values, list, idKey, nameKey) => {
const item = list?.find(v => v[idKey] === values[0]);
return {
id: values[0],
name: item ? item[nameKey] : values[0],
};
};
const getValuesForLabels = (values, labels) => {
const selectedLabels = labels.filter(label => values.includes(label.title));
return selectedLabels.map(({ title }) => ({
id: title,
name: title,
}));
};
const getValuesForLanguages = (values, languages) => {
const selectedLanguages = languages.filter(language =>
values.includes(language.id)
);
return selectedLanguages.map(({ id, name }) => ({
id: id.toLowerCase(),
name: name,
}));
};
const getValuesForCountries = (values, countries) => {
const selectedCountries = countries.filter(country =>
values.includes(country.id)
);
return selectedCountries.map(({ id, name }) => ({
id: id,
name: name,
}));
};
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const {
languages,
countries,
agents,
inboxes,
teams,
campaigns,
labels,
} = params;
switch (attribute_key) {
case 'assignee_id':
return getValuesName(values, agents, 'id', 'name');
case 'inbox_id':
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels': {
return getValuesForLabels(values, labels);
}
case 'browser_language': {
return getValuesForLanguages(values, languages);
}
case 'country_code': {
return getValuesForCountries(values, countries);
}
default:
return { id: values[0], name: values[0] };
}
};
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inboxType = getInputType(attribute_key, filter_operator, filterTypes);
if (inboxType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
);
return filterInputTypes === 'string'
? values[0].toString()
: { id: values[0], name: values[0] };
}
return inboxType === 'multi_select' || inboxType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};
@@ -1,9 +1,12 @@
const setArrayValues = item => {
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
};
const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => {
if (Array.isArray(item.values)) {
item.values = item.values.map(val => val.id);
item.values = setArrayValues(item);
} else if (typeof item.values === 'object') {
item.values = [item.values.id];
} else if (!item.values) {
@@ -18,18 +18,18 @@ export const initializeAnalyticsEvents = () => {
});
}
});
window.bus.$on(ANALYTICS_RESET, () => {});
};
const initializeAudioAlerts = user => {
// InitializeAudioNotifications
const { ui_settings: uiSettings } = user || {};
const {
always_play_audio_alert: alwaysPlayAudioAlert,
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
} = uiSettings;
// UI Settings can be undefined initally as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({
currentUserId: user.id,
@@ -0,0 +1,278 @@
import {
getAttributeInputType,
getInputType,
getValuesName,
getValuesForFilter,
generateValuesForEditCustomViews,
generateCustomAttributesInputType,
} from '../customViewsHelper';
import advancedFilterTypes from 'dashboard/components/widgets/conversation/advancedFilterItems/index';
describe('customViewsHelper', () => {
describe('#getInputType', () => {
it('should return plain_text if key is created_at or last_activity_at and operator is days_before', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
];
expect(getInputType('created_at', 'days_before', filterTypes)).toEqual(
'plain_text'
);
expect(
getInputType('last_activity_at', 'days_before', filterTypes)
).toEqual('plain_text');
});
it('should return inputType if key is not created_at or last_activity_at', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
it('should return undefined if key is not created_at or last_activity_at and inputType is not present', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
});
describe('#getAttributeInputType', () => {
it('should return multi_select if attribute_display_type is checkbox or list', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'multi_select'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'multi_select'
);
});
it('should return string if attribute_display_type is text, number, date or link', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'text' },
{ attribute_key: 'test2', attribute_display_type: 'number' },
{ attribute_key: 'test3', attribute_display_type: 'date' },
{ attribute_key: 'test4', attribute_display_type: 'link' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test3', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test4', allCustomAttributes)).toEqual(
'string'
);
});
});
describe('#getValuesName', () => {
it('should return id and name if item is present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [1];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and value if item is not present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [2];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 2,
name: 2,
});
});
});
describe('#getValuesForFilter', () => {
it('should return id and name if attribute_key is assignee_id', () => {
const filter = { attribute_key: 'assignee_id', values: [1] };
const params = { agents: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is inbox_id', () => {
const filter = { attribute_key: 'inbox_id', values: [1] };
const params = { inboxes: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is team_id', () => {
const filter = { attribute_key: 'team_id', values: [1] };
const params = { teams: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is campaign_id', () => {
const filter = { attribute_key: 'campaign_id', values: [1] };
const params = { campaigns: [{ id: 1, title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is labels', () => {
const filter = { attribute_key: 'labels', values: ['test'] };
const params = { labels: [{ title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'test', name: 'test' },
]);
});
it('should return id and name if attribute_key is browser_language', () => {
const filter = { attribute_key: 'browser_language', values: ['en'] };
const params = { languages: [{ id: 'en', name: 'English' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'en', name: 'English' },
]);
});
it('should return id and name if attribute_key is country_code', () => {
const filter = { attribute_key: 'country_code', values: ['IN'] };
const params = { countries: [{ id: 'IN', name: 'India' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'IN', name: 'India' },
]);
});
it('should return id and name if attribute_key is not present', () => {
const filter = { attribute_key: 'test', values: [1] };
const params = {};
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 1,
});
});
});
describe('#generateValuesForEditCustomViews', () => {
it('should return id and name if inboxType is multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is not multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is undefined', () => {
const filter = {
attribute_key: 'test2',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 1,
});
});
it('should return value as string if filterInputTypes is string', () => {
const filter = {
attribute_key: 'test',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'date' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
});
describe('#generateCustomAttributesInputType', () => {
it('should return string if type is text', () => {
expect(generateCustomAttributesInputType('text')).toEqual('string');
});
it('should return string if type is number', () => {
expect(generateCustomAttributesInputType('number')).toEqual('string');
});
it('should return string if type is date', () => {
expect(generateCustomAttributesInputType('date')).toEqual('string');
});
it('should return multi_select if type is checkbox', () => {
expect(generateCustomAttributesInputType('checkbox')).toEqual(
'multi_select'
);
});
it('should return multi_select if type is list', () => {
expect(generateCustomAttributesInputType('list')).toEqual('multi_select');
});
it('should return string if type is link', () => {
expect(generateCustomAttributesInputType('link')).toEqual('string');
});
});
});
@@ -2,12 +2,17 @@
"FILTER": {
"TITLE": "Filter Conversations",
"SUBTITLE": "Add filters below and hit 'Apply filters' to filter conversations.",
"EDIT_CUSTOM_FILTER": "Edit Folder",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your folder.",
"ADD_NEW_FILTER": "Add Filter",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Apply filters",
"UPDATE_BUTTON_LABEL": "Update folder",
"CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required",
"FOLDER_LABEL": "Folder Name",
"FOLDER_QUERY_LABEL": "Folder Query",
"TOOLTIP_LABEL": "Filter conversations",
"QUERY_DROPDOWN_LABELS": {
"AND": "AND",
@@ -71,6 +76,9 @@
"ERROR_MESSAGE": "Error while creating segment"
}
},
"EDIT": {
"EDIT_BUTTON": "Edit folder"
},
"DELETE": {
"DELETE_BUTTON": "Delete filter",
"MODAL": {
@@ -10,15 +10,39 @@
"TITLE": "Manage Audit Logs",
"DESC": "Audit Logs are trails for events and actions in a Chatwoot System.",
"TABLE_HEADER": [
"User",
"Action",
"IP Address",
"Time"
"Activity",
"Time",
"IP Address"
]
},
"API": {
"SUCCESS_MESSAGE": "AuditLogs retrieved successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"DEFAULT_USER": "System",
"AUTOMATION_RULE": {
"ADD": "%{agentName} created a new automation rule (#%{id})",
"EDIT": "%{agentName} updated an automation rule (#%{id})",
"DELETE": "%{agentName} deleted an automation rule (#%{id})"
},
"INBOX": {
"ADD": "%{agentName} created a new inbox (#%{id})",
"EDIT": "%{agentName} updated an inbox (#%{id})",
"DELETE": "%{agentName} deleted an inbox (#%{id})"
},
"WEBHOOK": {
"ADD": "%{agentName} created a new webhook (#%{id})",
"EDIT": "%{agentName} updated a webhook (#%{id})",
"DELETE": "%{agentName} deleted a webhook (#%{id})"
},
"USER_ACTION": {
"SIGN_IN": "%{agentName} signed in",
"SIGN_OUT": "%{agentName} signed out"
},
"TEAM": {
"ADD": "%{agentName} created a new team (#%{id})",
"EDIT": "%{agentName} updated a team (#%{id})",
"DELETE": "%{agentName} deleted a team (#%{id})"
}
}
}
@@ -73,6 +73,13 @@
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress, You will be notified via email when export file is ready to dowanlod.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"DELETE_NOTE": {
"CONFIRM": {
"TITLE": "Confirm Deletion",
@@ -211,6 +218,7 @@
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": {
"LOADING_MESSAGE": "Loading contacts...",
"404": "No contacts matches your search 🔍",
@@ -1,50 +1,55 @@
{
"CONTACTS_FILTER": {
"TITLE": "Filter Contacts",
"SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.",
"ADD_NEW_FILTER": "Add Filter",
"CLEAR_ALL_FILTERS": "Clear All Filters",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Submit",
"CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required",
"TOOLTIP_LABEL": "Filter contacts",
"QUERY_DROPDOWN_LABELS": {
"AND": "AND",
"OR": "OR"
},
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
"contains": "Contains",
"does_not_contain": "Does not contain",
"is_present": "Is present",
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
"PHONE_NUMBER": "Phone number",
"IDENTIFIER": "Identifier",
"CITY": "City",
"COUNTRY": "Country",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Custom Attributes"
}
"CONTACTS_FILTER": {
"TITLE": "Filter Contacts",
"SUBTITLE": "Add filters below and hit 'Submit' to filter contacts.",
"EDIT_CUSTOM_SEGMENT": "Edit Segment",
"CUSTOM_VIEWS_SUBTITLE": "Add or remove filters and update your segment.",
"ADD_NEW_FILTER": "Add Filter",
"CLEAR_ALL_FILTERS": "Clear All Filters",
"FILTER_DELETE_ERROR": "You should have atleast one filter to save",
"SUBMIT_BUTTON_LABEL": "Submit",
"UPDATE_BUTTON_LABEL": "Update Segment",
"CANCEL_BUTTON_LABEL": "Cancel",
"CLEAR_BUTTON_LABEL": "Clear Filters",
"EMPTY_VALUE_ERROR": "Value is required",
"SEGMENT_LABEL": "Segment Name",
"SEGMENT_QUERY_LABEL": "Segment Query",
"TOOLTIP_LABEL": "Filter contacts",
"QUERY_DROPDOWN_LABELS": {
"AND": "AND",
"OR": "OR"
},
"OPERATOR_LABELS": {
"equal_to": "Equal to",
"not_equal_to": "Not equal to",
"contains": "Contains",
"does_not_contain": "Does not contain",
"is_present": "Is present",
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
"PHONE_NUMBER": "Phone number",
"IDENTIFIER": "Identifier",
"CITY": "City",
"COUNTRY": "Country",
"CUSTOM_ATTRIBUTE_LIST": "List",
"CUSTOM_ATTRIBUTE_TEXT": "Text",
"CUSTOM_ATTRIBUTE_NUMBER": "Number",
"CUSTOM_ATTRIBUTE_LINK": "Link",
"CUSTOM_ATTRIBUTE_CHECKBOX": "Checkbox",
"CREATED_AT": "Created At",
"LAST_ACTIVITY": "Last Activity",
"REFERER_LINK": "Referrer link"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard Filters",
"ADDITIONAL_FILTERS": "Additional Filters",
"CUSTOM_ATTRIBUTES": "Custom Attributes"
}
}
}
@@ -74,6 +74,14 @@
"DELETE": "Delete article"
}
},
"ARTICLE_SEARCH_RESULT": {
"UNCATEGORIZED": "Uncategorized",
"INSERT_ARTICLE": "Insert",
"NO_RESULT": "No articles found",
"COPY_LINK": "Copy article link to clipboard",
"OPEN_LINK": "Open article in new tab",
"PREVIEW_LINK": "Preview article"
},
"PORTAL": {
"HEADER": "Portals",
"DEFAULT": "Default",
@@ -83,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Enable channel greeting",
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.",
"HELP_TEXT": "Auto-send greeting messages when customers start a conversation and send their first message.",
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
@@ -535,7 +535,6 @@
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {
"ENABLE": "Enable availability for this day",
+46 -17
View File
@@ -2,39 +2,68 @@ import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
currentAccountId: 'getCurrentAccountId',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
},
...mapGetters({ currentUser: 'getCurrentUser' }),
isAgentSelected() {
return this.currentChat?.meta?.assignee;
},
createNoneAgent() {
return {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
},
agentsList() {
const agents = this.assignableAgents || [];
return [
...(this.isAgentSelected
? [
{
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
},
]
: []),
...agents,
].map(item =>
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
const none = this.createNoneAgent;
const filteredAgentsByAvailability = this.sortedAgentsByAvailability(
agentsByUpdatedPresence
);
const filteredAgents = [
...(this.isAgentSelected ? [none] : []),
...filteredAgentsByAvailability,
];
return filteredAgents;
},
},
methods: {
getAgentsByAvailability(agents, availability) {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
},
sortedAgentsByAvailability(agents) {
const onlineAgents = this.getAgentsByAvailability(agents, 'online');
const busyAgents = this.getAgentsByAvailability(agents, 'busy');
const offlineAgents = this.getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
},
getAgentsByUpdatedPresence(agents) {
// Here we are updating the availability status of the current user dynamically (live) based on the current account availability status
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === this.currentUser.id
? {
...item,
availability_status: this.currentUser.availability_status,
availability_status: this.currentUser.accounts.find(
account => account.id === this.currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
},
},
};
@@ -20,6 +20,36 @@ export default {
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
],
formattedAgents: [
{
@@ -32,7 +62,17 @@ export default {
},
{
account_id: 1,
availability_status: 'busy',
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
@@ -40,6 +80,70 @@ export default {
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
onlineAgents: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
],
busyAgents: [
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
@@ -51,4 +155,92 @@ export default {
role: 'agent',
},
],
offlineAgents: [
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
sortedByAvailability: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
],
formattedAgentsByPresenceOnline: [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
],
formattedAgentsByPresenceOffline: [
{
account_id: 1,
availability_status: 'offline',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
],
};
@@ -12,12 +12,71 @@ describe('agentMixin', () => {
getters = {
getCurrentUser: () => ({
id: 1,
availability_status: 'busy',
accounts: [
{
id: 1,
availability_status: 'online',
auto_offline: false,
},
],
}),
getCurrentAccountId: () => 1,
};
store = new Vuex.Store({ getters });
});
it('return agents by availability', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'online')
).toEqual(agentFixtures.onlineAgents);
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'busy')
).toEqual(agentFixtures.busyAgents);
expect(
wrapper.vm.getAgentsByAvailability(agentFixtures.allAgents, 'offline')
).toEqual(agentFixtures.offlineAgents);
});
it('return sorted agents by availability', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.sortedAgentsByAvailability(agentFixtures.allAgents)
).toEqual(agentFixtures.sortedByAvailability);
});
it('return formatted agents', () => {
const Component = {
render() {},
@@ -38,4 +97,44 @@ describe('agentMixin', () => {
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.agentsList).toEqual(agentFixtures.formattedAgents);
});
it('return formatted agents by presence', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [agentMixin],
data() {
return {
inboxId: 1,
currentChat: { meta: { assignee: { name: 'John' } } },
};
},
computed: {
currentUser() {
return {
id: 1,
accounts: [
{
id: 1,
availability_status: 'offline',
auto_offline: false,
},
],
};
},
currentAccountId() {
return 1;
},
assignableAgents() {
return agentFixtures.allAgents;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(
wrapper.vm.getAgentsByUpdatedPresence(
agentFixtures.formattedAgentsByPresenceOnline
)
).toEqual(agentFixtures.formattedAgentsByPresenceOffline);
});
});
@@ -1,9 +1,25 @@
<template>
<div class="column">
<woot-modal-header :header-title="$t('CONTACTS_FILTER.TITLE')">
<p>{{ $t('CONTACTS_FILTER.SUBTITLE') }}</p>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p>{{ filterModalSubTitle }}</p>
</woot-modal-header>
<div class="row modal-content">
<div class="column modal-content">
<div v-if="isSegmentsView" class="columns">
<label class="input-label" :class="{ error: !activeSegmentNewName }">
{{ $t('CONTACTS_FILTER.SEGMENT_LABEL') }}
<input
v-model="activeSegmentNewName"
type="text"
class="name-input"
/>
<span v-if="!activeSegmentNewName" class="message">
{{ $t('CONTACTS_FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('CONTACTS_FILTER.SEGMENT_QUERY_LABEL') }}
</label>
</div>
<div class="medium-12 columns filters-wrap">
<filter-input-box
v-for="(filter, i) in appliedFilters"
@@ -36,7 +52,7 @@
{{ $t('CONTACTS_FILTER.ADD_NEW_FILTER') }}
</woot-button>
<woot-button
v-if="hasAppliedFilters"
v-if="hasAppliedFilters && !isSegmentsView"
icon="subtract"
color-scheme="alert"
variant="smooth"
@@ -52,7 +68,14 @@
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('CONTACTS_FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button @click="submitFilterQuery">
<woot-button
v-if="isSegmentsView"
:disabled="!activeSegmentNewName"
@click="updateSegment"
>
{{ $t('CONTACTS_FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('CONTACTS_FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
@@ -85,6 +108,18 @@ export default {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
isSegmentsView: {
type: Boolean,
default: false,
},
activeSegmentName: {
type: String,
default: '',
},
},
validations: {
appliedFilters: {
@@ -105,7 +140,8 @@ export default {
data() {
return {
show: true,
appliedFilters: [],
appliedFilters: this.initialAppliedFilters,
activeSegmentNewName: this.activeSegmentName,
filterTypes: this.initialFilterTypes,
filterGroups: [],
allCustomAttributes: [],
@@ -121,12 +157,22 @@ export default {
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
filterModalHeaderTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.TITLE')
: this.$t('CONTACTS_FILTER.EDIT_CUSTOM_SEGMENT');
},
filterModalSubTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.SUBTITLE')
: this.$t('CONTACTS_FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
this.setFilterAttributes();
if (this.getAppliedContactFilters.length) {
this.appliedFilters = [...this.getAppliedContactFilters];
} else {
} else if (!this.isSegmentsView) {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
@@ -177,11 +223,11 @@ export default {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.filterOperators;
return type?.filterOperators;
},
getDropdownValues(type) {
const allCustomAttributes = this.$store.getters[
@@ -230,11 +276,30 @@ export default {
}
},
appendNewFilter() {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
if (this.isSegmentsView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
@@ -259,6 +324,13 @@ export default {
})),
});
},
updateSegment() {
this.$emit(
'updateSegment',
this.appliedFilters,
this.activeSegmentNewName
);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
@@ -1,10 +1,11 @@
<template>
<div class="contacts-page row">
<div class="left-wrap" :class="wrapClas">
<div class="left-wrap" :class="wrapClass">
<contacts-header
:search-query="searchQuery"
:segments-id="segmentsId"
:on-search-submit="onSearchSubmit"
:on-export-submit="onExportSubmit"
this-selected-contact-id=""
:on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate"
@@ -13,6 +14,7 @@
:header-title="pageTitle"
@on-toggle-save-filter="onToggleSaveFilters"
@on-toggle-delete-filter="onToggleDeleteFilters"
@on-toggle-edit-filter="onToggleFilters"
/>
<contacts-table
:contacts="records"
@@ -58,14 +60,18 @@
</woot-modal>
<woot-modal
:show.sync="showFiltersModal"
:on-close="onToggleFilters"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<contacts-advanced-filters
v-if="showFiltersModal"
:on-close="onToggleFilters"
:on-close="closeAdvanceFiltersModal"
:initial-filter-types="contactFilterItems"
:initial-applied-filters="appliedFilter"
:active-segment-name="activeSegmentName"
:is-segments-view="hasActiveSegments"
@applyFilter="onApplyFilter"
@updateSegment="onUpdateSegment"
@clearFilters="clearFilters"
/>
</woot-modal>
@@ -87,6 +93,9 @@ import filterQueryGenerator from '../../../../helper/filterQueryGenerator';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import alertMixin from 'shared/mixins/alertMixin';
import countries from 'shared/constants/countries.js';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
const DEFAULT_PAGE = 1;
const FILTER_TYPE_CONTACT = 1;
@@ -103,6 +112,7 @@ export default {
AddCustomViews,
DeleteCustomViews,
},
mixins: [alertMixin],
props: {
label: { type: String, default: '' },
segmentsId: {
@@ -128,6 +138,7 @@ export default {
filterType: FILTER_TYPE_CONTACT,
showAddSegmentsModal: false,
showDeleteSegmentsModal: false,
appliedFilter: [],
};
},
computed: {
@@ -175,7 +186,7 @@ export default {
showContactViewPane() {
return this.selectedContactId !== '';
},
wrapClas() {
wrapClass() {
return this.showContactViewPane ? 'medium-9' : 'medium-12';
},
pageParameter() {
@@ -194,6 +205,9 @@ export default {
}
return undefined;
},
activeSegmentName() {
return this.activeSegment?.name;
},
},
watch: {
label() {
@@ -345,7 +359,14 @@ export default {
});
},
onToggleFilters() {
this.showFiltersModal = !this.showFiltersModal;
if (this.hasActiveSegments) {
this.initializeSegmentToFilterModal(this.activeSegment);
}
this.showFiltersModal = true;
},
closeAdvanceFiltersModal() {
this.showFiltersModal = false;
this.appliedFilter = [];
},
onApplyFilter(payload) {
this.closeContactInfoPanel();
@@ -355,10 +376,69 @@ export default {
});
this.showFiltersModal = false;
},
onUpdateSegment(payload, segmentName) {
const payloadData = {
...this.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
clearFilters() {
this.$store.dispatch('contacts/clearContactFilters');
this.fetchContacts(this.pageParameter);
},
onExportSubmit() {
try {
this.$store.dispatch('contacts/export');
this.showAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
error.message || this.$t('EXPORT_CONTACTS.ERROR_MESSAGE')
);
}
},
setParamsForEditSegmentModal() {
// Here we are setting the params for edit segment modal to show the existing values.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit segment modal.
const params = {
countries: countries,
filterTypes: contactFilterItems,
allCustomAttributes: this.$store.getters[
'attributes/getAttributesByModel'
]('contact_attribute'),
};
return params;
},
initializeSegmentToFilterModal(activeSegment) {
// Here we are setting the params for edit segment modal.
// To show the existing values. when we click on edit segment button.
// Here we get the query from the active segment.
// And we are mapping the query to the actual values.
// To show in the edit segment modal by the help of generateValuesForEditCustomViews helper.
const query = activeSegment?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditSegmentModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
openSavedItemInSegment() {
const lastItemInSegments = this.segments[this.segments.length - 1];
const lastItemId = lastItemInSegments.id;
@@ -27,15 +27,24 @@
{{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
</woot-button>
</div>
<woot-button
v-if="hasActiveSegments"
class="margin-right-1 clear"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
<div v-if="hasActiveSegments">
<woot-button
class="margin-right-1 clear"
color-scheme="secondary"
icon="edit"
@click="onToggleEditSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_EDIT') }}
</woot-button>
<woot-button
class="margin-right-1 clear"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
</div>
<div v-if="!hasActiveSegments" class="filters__button-wrap">
<div v-if="hasAppliedFilters" class="filters__applied-indicator" />
<woot-button
@@ -78,6 +87,16 @@
>
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="upload"
class="clear"
@click="onExportSubmit"
>
{{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</header>
@@ -118,6 +137,10 @@ export default {
type: Function,
default: () => {},
},
onExportSubmit: {
type: Function,
default: () => {},
},
onToggleFilter: {
type: Function,
default: () => {},
@@ -147,6 +170,9 @@ export default {
onToggleSegmentsModal() {
this.$emit('on-toggle-save-filter');
},
onToggleEditSegmentsModal() {
this.$emit('on-toggle-edit-filter');
},
onToggleDeleteSegmentsModal() {
this.$emit('on-toggle-delete-filter');
},
@@ -98,7 +98,7 @@ export default {
const errorMessage = error?.message;
this.alertMessage =
errorMessage || this.filterType === 0
? this.$t('FILTER.CUSTOM_VIEWS.ADD.API_FOLDERS.ERROR_MESSAGE')
? errorMessage
: this.$t('FILTER.CUSTOM_VIEWS.ADD.API_SEGMENTS.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
@@ -0,0 +1,130 @@
<template>
<div class="article-item">
<h4 class="text-block-title margin-bottom-0">{{ title }}</h4>
<p class="margin-bottom-0 text-truncate">{{ body }}</p>
<div class="footer">
<p class="text-small meta">
{{ locale }}
{{ ` / ` }}
{{
category ||
$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.ARTICLE_SEARCH_RESULT')
}}
</p>
<div class="action-buttons">
<woot-button
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.COPY_LINK')"
variant="hollow"
color-scheme="secondary"
size="tiny"
icon="copy"
@click="handleCopy"
/>
<a
:href="url"
class="button hollow button--only-icon tiny secondary"
rel="noopener noreferrer nofollow"
target="_blank"
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.OPEN_LINK')"
>
<fluent-icon size="12" icon="arrow-up-right" />
<span class="show-for-sr">{{ url }}</span>
</a>
<woot-button
variant="hollow"
color-scheme="secondary"
size="tiny"
icon="preview-link"
:title="$t('HELP_CENTER.ARTICLE_SEARCH_RESULT.PREVIEW_LINK')"
@click="handlePreview"
/>
<woot-button
class="insert-button"
variant="smooth"
color-scheme="secondary"
size="tiny"
@click="handleClick"
>
{{ $t('HELP_CENTER.ARTICLE_SEARCH_RESULT.INSERT_ARTICLE') }}
</woot-button>
</div>
</div>
</div>
</template>
<script>
import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default {
name: 'ArticleSearchResultItem',
props: {
title: {
type: String,
default: 'Untitled',
},
body: {
type: String,
default: '',
},
url: {
type: String,
default: '',
},
category: {
type: String,
default: '',
},
locale: {
type: String,
default: 'en-US',
},
},
methods: {
handleClick() {
this.$emit('click');
},
handlePreview() {
this.$emit('preview');
},
async handleCopy() {
await copyTextToClipboard(this.url);
this.showAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
},
};
</script>
<style lang="scss" scoped>
.article-item {
display: flex;
flex-direction: column;
gap: var(--space-micro);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-micro);
}
.meta {
color: var(--s-600);
margin-bottom: 0;
}
.action-buttons {
display: flex;
gap: var(--space-micro);
}
.action-buttons .button:not(.insert-button) {
visibility: hidden;
opacity: 0;
transition: all 0.1s ease-in;
}
.article-item:hover .action-buttons .button:not(.insert-button) {
visibility: visible;
opacity: 1;
}
</style>
@@ -0,0 +1,56 @@
import ArticleSearchResultItem from '../ArticleSearchResultItem.vue';
export default {
title: 'Components/Help Center/ArticleSearchResultItem',
component: ArticleSearchResultItem,
argTypes: {
title: {
defaultValue: 'Setup your account',
control: {
type: 'text',
},
},
body: {
defaultValue:
'You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.',
control: {
type: 'text',
},
},
category: {
defaultValue: 'Getting started',
control: {
type: 'text',
},
},
locale: {
defaultValue: 'en-US',
control: {
type: 'text',
},
},
url: {
defaultValue: '/app/accounts/1/conversations/23842',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ArticleSearchResultItem },
template:
'<ArticleSearchResultItem v-bind="$props" ></ArticleSearchResultItem>',
});
export const ArticleSearchResultItemStory = Template.bind({});
ArticleSearchResultItemStory.args = {
title: 'Setup your account',
body: `You can integrate your Chatwoot account with multiple conversation channels like website live-chat, email, Facebook page, Twitter handle, WhatsApp, etc. You can view all of your conversations from different channels on one dashboard. This helps in reducing the time and friction involved with switching between multiple tools.
You can manage your conversations and collaborate with your team on the go with Chatwoot mobile apps (available for Android and iOS).
In this user guide, weve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
};
@@ -1,8 +1,8 @@
<template>
<div class="column content-box">
<div class="column content-box audit-log--settings">
<!-- List Audit Logs -->
<div class="row">
<div class="small-8 columns with-right-space ">
<div>
<div>
<p
v-if="!uiFlags.fetchingList && !records.length"
class="no-items-error-message"
@@ -16,8 +16,13 @@
<table
v-if="!uiFlags.fetchingList && records.length"
class="woot-table"
class="woot-table width-100"
>
<colgroup>
<col class="column-activity" />
<col />
<col />
</colgroup>
<thead>
<!-- Header -->
<th
@@ -29,16 +34,20 @@
</thead>
<tbody>
<tr v-for="auditLogItem in records" :key="auditLogItem.id">
<td class="wrap-break-words">{{ auditLogItem.username }}</td>
<td class="wrap-break-words">
{{ auditLogItem.auditable_type }}.{{ auditLogItem.action }}
{{ generateLogText(auditLogItem) }}
</td>
<td class="wrap-break-words">
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td>
<td class="remote-address">
{{ auditLogItem.remote_address }}
</td>
<td class="wrap-break-words">
{{ dynamicTime(auditLogItem.created_at) }}
</td>
</tr>
</tbody>
</table>
@@ -76,13 +85,55 @@ export default {
records: 'auditlogs/getAuditLogs',
uiFlags: 'auditlogs/getUIFlags',
meta: 'auditlogs/getMeta',
agentList: 'agents/getAgents',
}),
},
mounted() {
// Fetch API Call
this.$store.dispatch('auditlogs/fetch', { page: 1 });
this.$store.dispatch('agents/get');
},
methods: {
getAgentName(email) {
if (email === null) {
return this.$t('AUDIT_LOGS.DEFAULT_USER');
}
const agentName = this.agentList.find(agent => agent.email === email)
?.name;
// If agent does not exist(removed/deleted), return email from audit log
return agentName || email;
},
generateLogText(auditLogItem) {
const agentName = this.getAgentName(auditLogItem.username);
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
const auditId = auditLogItem.auditable_id;
const logActionKey = `${auditableType}:${action}`;
const translationPayload = {
agentName,
id: auditId,
};
const translationKeys = {
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
'team:create': `AUDIT_LOGS.TEAM.ADD`,
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
};
return this.$t(translationKeys[logActionKey] || '', translationPayload);
},
onPageChange(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
try {
@@ -96,12 +147,24 @@ export default {
},
};
</script>
<style scoped>
.remote-address {
width: 14rem;
}
.wrap-break-words {
word-break: break-all;
white-space: normal;
<style lang="scss" scoped>
.audit-log--settings {
display: flex;
justify-content: space-between;
flex-direction: column;
.remote-address {
width: 14rem;
}
.wrap-break-words {
word-break: break-all;
white-space: normal;
}
.column-activity {
width: 60%;
}
}
</style>
@@ -102,9 +102,7 @@ export default {
data() {
return {
isBusinessHoursEnabled: false,
unavailableMessage: this.$t(
'INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT'
),
unavailableMessage: '',
timeZone: DEFAULT_TIMEZONE,
dayNames: {
0: 'Sunday',
@@ -157,9 +155,7 @@ export default {
? timeSlotParse(timeSlots)
: defaultTimeSlot;
this.isBusinessHoursEnabled = isEnabled;
this.unavailableMessage =
unavailableMessage ||
this.$t('INBOX_MGMT.BUSINESS_HOURS.UNAVAILABLE_MESSAGE_DEFAULT');
this.unavailableMessage = unavailableMessage || '';
this.timeSlots = slots;
this.timeZone =
this.timeZones.find(item => timeZone === item.value) ||
@@ -16,7 +16,7 @@
>
{{ $t('CSAT_REPORTS.DOWNLOAD') }}
</woot-button>
<csat-metrics />
<csat-metrics :filters="requestPayload" />
<csat-table :page-index="pageIndex" @page-change="onPageNumberChange" />
</div>
</template>
@@ -92,7 +92,7 @@ export default {
}
if (!this.accountReport.data.length) return {};
const labels = this.accountReport.data.map(element => {
if (this.groupBy.period === GROUP_BY_FILTER[2].period) {
if (this.groupBy?.period === GROUP_BY_FILTER[2].period) {
let week_date = new Date(fromUnixTime(element.timestamp));
const first_day = week_date.getDate() - week_date.getDay();
const last_day = first_day + 6;
@@ -105,10 +105,10 @@ export default {
'dd/MM/yy'
)}`;
}
if (this.groupBy.period === GROUP_BY_FILTER[3].period) {
if (this.groupBy?.period === GROUP_BY_FILTER[3].period) {
return format(fromUnixTime(element.timestamp), 'MMM-yyyy');
}
if (this.groupBy.period === GROUP_BY_FILTER[4].period) {
if (this.groupBy?.period === GROUP_BY_FILTER[4].period) {
return format(fromUnixTime(element.timestamp), 'yyyy');
}
return format(fromUnixTime(element.timestamp), 'dd-MMM-yyyy');
@@ -213,7 +213,7 @@ export default {
return {
from,
to,
groupBy: groupBy.period,
groupBy: groupBy?.period,
businessHours,
};
},
@@ -1,5 +1,10 @@
<template>
<div class="medium-2 small-6 csat--metric-card">
<div
class="medium-2 small-6 csat--metric-card"
:class="{
disabled: disabled,
}"
>
<h3 class="heading">
<span>{{ label }}</span>
<fluent-icon
@@ -29,6 +34,10 @@ export default {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -37,6 +46,13 @@ export default {
margin: 0;
padding: var(--space-normal);
&.disabled {
// grayscale everything
filter: grayscale(100%);
opacity: 0.3;
pointer-events: none;
}
.heading {
align-items: center;
color: var(--color-heading);
@@ -6,16 +6,20 @@
:value="responseCount"
/>
<csat-metric-card
:disabled="ratingFilterEnabled"
:label="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP')"
:value="formatToPercent(satisfactionScore)"
:value="ratingFilterEnabled ? '--' : formatToPercent(satisfactionScore)"
/>
<csat-metric-card
:label="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL')"
:info-text="$t('CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP')"
:value="formatToPercent(responseRate)"
/>
<div v-if="metrics.totalResponseCount" class="medium-6 report-card">
<div
v-if="metrics.totalResponseCount && !ratingFilterEnabled"
class="medium-6 report-card"
>
<h3 class="heading">
<div class="emoji--distribution">
<div
@@ -24,7 +28,7 @@
class="emoji--distribution-item"
>
<span class="emoji--distribution-key">{{
csatRatings[key - 1].emoji
ratingToEmoji(key)
}}</span>
<span>{{ formatToPercent(rating) }}</span>
</div>
@@ -45,6 +49,12 @@ export default {
components: {
CsatMetricCard,
},
props: {
filters: {
type: Object,
required: true,
},
},
data() {
return {
csatRatings: CSAT_RATINGS,
@@ -57,12 +67,15 @@ export default {
satisfactionScore: 'csat/getSatisfactionScore',
responseRate: 'csat/getResponseRate',
}),
ratingFilterEnabled() {
return Boolean(this.filters.rating);
},
chartData() {
return {
labels: ['Rating'],
datasets: CSAT_RATINGS.map((rating, index) => ({
datasets: CSAT_RATINGS.map(rating => ({
label: rating.emoji,
data: [this.ratingPercentage[index + 1]],
data: [this.ratingPercentage[rating.value]],
backgroundColor: rating.color,
})),
};
@@ -77,6 +90,9 @@ export default {
formatToPercent(value) {
return value ? `${value}%` : '--';
},
ratingToEmoji(value) {
return CSAT_RATINGS.find(rating => rating.value === Number(value)).emoji;
},
},
};
</script>
@@ -231,7 +231,7 @@ export default {
<style scoped>
.filter-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: var(--space-slab);
margin-bottom: var(--space-normal);
@@ -0,0 +1,64 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import CsatMetrics from '../CsatMetrics.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['csat-metric-card', 'woot-horizontal-bar'],
};
describe('CsatMetrics.vue', () => {
let getters;
let store;
let wrapper;
const filters = { rating: 3 };
beforeEach(() => {
getters = {
'csat/getMetrics': () => ({ totalResponseCount: 100 }),
'csat/getRatingPercentage': () => ({ 1: 10, 2: 20, 3: 30, 4: 30, 5: 10 }),
'csat/getSatisfactionScore': () => 85,
'csat/getResponseRate': () => 90,
};
store = new Vuex.Store({
getters,
});
wrapper = shallowMount(CsatMetrics, {
store,
localVue,
propsData: { filters },
...mountParams,
});
});
it('computes response count correctly', () => {
expect(wrapper.vm.responseCount).toBe('100');
expect(wrapper.html()).toMatchSnapshot();
});
it('formats values to percent correctly', () => {
expect(wrapper.vm.formatToPercent(85)).toBe('85%');
expect(wrapper.vm.formatToPercent(null)).toBe('--');
});
it('maps rating value to emoji correctly', () => {
const rating = wrapper.vm.csatRatings[0]; // assuming this is { value: 1, emoji: '😡' }
expect(wrapper.vm.ratingToEmoji(rating.value)).toBe(rating.emoji);
});
it('hides report card if rating filter is enabled', () => {
expect(wrapper.find('.report-card').exists()).toBe(false);
});
it('shows report card if rating filter is not enabled', async () => {
await wrapper.setProps({ filters: {} });
expect(wrapper.find('.report-card').exists()).toBe(true);
});
});
@@ -0,0 +1,46 @@
import { createLocalVue, shallowMount } from '@vue/test-utils';
import CsatMetricCard from '../CsatMetricCard.vue';
import VTooltip from 'v-tooltip';
const localVue = createLocalVue();
localVue.use(VTooltip);
describe('CsatMetricCard.vue', () => {
it('renders props correctly', () => {
const label = 'Total Responses';
const value = '100';
const infoText = 'Total number of responses';
const wrapper = shallowMount(CsatMetricCard, {
propsData: { label, value, infoText },
localVue,
stubs: ['fluent-icon'],
});
expect(wrapper.find('.heading span').text()).toMatch(label);
expect(wrapper.find('.metric').text()).toMatch(value);
expect(wrapper.find('.csat--icon').classes()).toContain('has-tooltip');
});
it('adds disabled class when disabled prop is true', () => {
const wrapper = shallowMount(CsatMetricCard, {
propsData: { label: '', value: '', infoText: '', disabled: true },
localVue,
stubs: ['fluent-icon'],
});
expect(wrapper.find('.csat--metric-card').classes()).toContain('disabled');
});
it('does not add disabled class when disabled prop is false', () => {
const wrapper = shallowMount(CsatMetricCard, {
propsData: { label: '', value: '', infoText: '', disabled: false },
localVue,
stubs: ['fluent-icon'],
});
expect(wrapper.find('.csat--metric-card').classes()).not.toContain(
'disabled'
);
});
});
@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CsatMetrics.vue computes response count correctly 1`] = `
<div class="row csat--metrics-container">
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL" value="100" infotext="CSAT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.LABEL" value="--" infotext="CSAT_REPORTS.METRIC.SATISFACTION_SCORE.TOOLTIP" disabled="true"></csat-metric-card-stub>
<csat-metric-card-stub label="CSAT_REPORTS.METRIC.RESPONSE_RATE.LABEL" value="90%" infotext="CSAT_REPORTS.METRIC.RESPONSE_RATE.TOOLTIP"></csat-metric-card-stub>
<!---->
</div>
`;
@@ -140,6 +140,20 @@ export const actions = {
}
},
export: async ({ commit }) => {
try {
await ContactAPI.exportContacts();
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
throw new Error(error);
}
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
try {
@@ -86,6 +86,18 @@ const actions = {
}
},
fetchAllAttachments: async ({ commit }, conversationId) => {
try {
const { data } = await ConversationApi.getAllAttachments(conversationId);
commit(types.SET_ALL_ATTACHMENTS, {
id: conversationId,
data: data.payload,
});
} catch (error) {
// Handle error
}
},
syncActiveConversationMessages: async (
{ commit, state, dispatch },
{ conversationId }
@@ -247,6 +259,10 @@ const actions = {
...response.data,
status: MESSAGE_STATUS.SENT,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
} catch (error) {
const errorMessage = error.response
? error.response.data.error
@@ -269,6 +285,7 @@ const actions = {
conversationId: message.conversation_id,
canReply: true,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
},
@@ -283,6 +300,7 @@ const actions = {
try {
const { data } = await MessageApi.delete(conversationId, messageId);
commit(types.ADD_MESSAGE, data);
commit(types.DELETE_CONVERSATION_ATTACHMENTS, data);
} catch (error) {
throw new Error(error);
}
@@ -32,6 +32,11 @@ const getters = {
);
return selectedChat || {};
},
getSelectedChatAttachments: (_state, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { attachments } = selectedChat;
return attachments;
},
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
@@ -3,6 +3,7 @@ import types from '../../mutation-types';
import getters, { getSelectedChatConversation } from './getters';
import actions from './actions';
import { findPendingMessageIndex } from './helpers';
import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
@@ -56,6 +57,12 @@ export const mutations = {
chat.messages.unshift(...data);
}
},
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
Vue.set(chat, 'attachments', []);
chat.attachments.push(...data);
},
[types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
@@ -115,6 +122,44 @@ export const mutations = {
Vue.set(chat, 'muted', false);
},
[types.ADD_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const isMessageSent =
message.status === MESSAGE_STATUS.SENT && message.attachments;
if (isMessageSent) {
message.attachments.forEach(attachment => {
if (!chat.attachments.some(a => a.id === attachment.id)) {
chat.attachments.push(attachment);
}
});
}
},
[types.DELETE_CONVERSATION_ATTACHMENTS]({ allConversations }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const isMessageSent = message.status === MESSAGE_STATUS.SENT;
if (isMessageSent) {
const attachmentIndex = chat.attachments.findIndex(
a => a.message_id === message.id
);
if (attachmentIndex !== -1) chat.attachments.splice(attachmentIndex, 1);
}
},
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
@@ -49,6 +49,18 @@ export const actions = {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews({ commit }, obj) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
commit(types.UPDATE_CUSTOM_VIEW, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
@@ -72,6 +84,7 @@ export const mutations = {
[types.ADD_CUSTOM_VIEW]: MutationHelpers.create,
[types.SET_CUSTOM_VIEW]: MutationHelpers.set,
[types.UPDATE_CUSTOM_VIEW]: MutationHelpers.update,
[types.DELETE_CUSTOM_VIEW]: MutationHelpers.destroy,
};
@@ -204,6 +204,7 @@ describe('#actions', () => {
]);
});
});
describe('#addMessage', () => {
it('sends correct mutations if message is incoming', () => {
const message = {
@@ -218,6 +219,7 @@ describe('#actions', () => {
types.SET_CONVERSATION_CAN_REPLY,
{ conversationId: 1, canReply: true },
],
[types.ADD_CONVERSATION_ATTACHMENTS, message],
]);
});
it('sends correct mutations if message is not an incoming message', () => {
@@ -436,10 +438,13 @@ describe('#actions', () => {
describe('#deleteMessage', () => {
it('sends correct actions if API is success', async () => {
const [conversationId, messageId] = [1, 1];
axios.delete.mockResolvedValue({ data: { id: 1, content: 'deleted' } });
axios.delete.mockResolvedValue({
data: { id: 1, content: 'deleted' },
});
await actions.deleteMessage({ commit }, { conversationId, messageId });
expect(commit.mock.calls).toEqual([
[types.ADD_MESSAGE, { id: 1, content: 'deleted' }],
[types.DELETE_CONVERSATION_ATTACHMENTS, { id: 1, content: 'deleted' }],
]);
});
it('sends no actions if API is error', async () => {
@@ -554,4 +559,40 @@ describe('#addMentions', () => {
],
]);
});
describe('#fetchAllAttachments', () => {
it('fetches all attachments', async () => {
axios.get.mockResolvedValue({
data: {
payload: [
{
id: 1,
message_id: 1,
file_type: 'image',
data_url: '',
thumb_url: '',
},
],
},
});
await actions.fetchAllAttachments({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.SET_ALL_ATTACHMENTS,
{
id: 1,
data: [
{
id: 1,
message_id: 1,
file_type: 'image',
data_url: '',
thumb_url: '',
},
],
},
],
]);
});
});
});
@@ -305,4 +305,34 @@ describe('#getters', () => {
});
});
});
describe('#getSelectedChatAttachments', () => {
it('Returns attachments in selected chat', () => {
const state = {};
const getSelectedChat = {
attachments: [
{
id: 1,
file_name: 'test1',
},
{
id: 2,
file_name: 'test2',
},
],
};
expect(
getters.getSelectedChatAttachments(state, { getSelectedChat })
).toEqual([
{
id: 1,
file_name: 'test1',
},
{
id: 2,
file_name: 'test2',
},
]);
});
});
});
@@ -278,4 +278,129 @@ describe('#mutations', () => {
expect(state.appliedFilters).toEqual([]);
});
});
describe('#SET_ALL_ATTACHMENTS', () => {
it('set all attachments', () => {
const state = {
allConversations: [{ id: 1 }],
};
const data = [{ id: 1, name: 'test' }];
mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
expect(state.allConversations[0].attachments).toEqual(data);
});
it('set attachments key even if the attachments are empty', () => {
const state = {
allConversations: [{ id: 1 }],
};
const data = [];
mutations[types.SET_ALL_ATTACHMENTS](state, { id: 1, data });
expect(state.allConversations[0].attachments).toEqual([]);
});
});
describe('#ADD_CONVERSATION_ATTACHMENTS', () => {
it('add conversation attachments', () => {
const state = {
allConversations: [{ id: 1, attachments: [] }],
};
const message = {
conversation_id: 1,
status: 'sent',
attachments: [{ id: 1, name: 'test' }],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toEqual(
message.attachments
);
});
it('should not add duplicate attachments', () => {
const state = {
allConversations: [
{
id: 1,
attachments: [{ id: 1, name: 'existing' }],
},
],
};
const message = {
conversation_id: 1,
status: 'sent',
attachments: [
{ id: 1, name: 'existing' },
{ id: 2, name: 'new' },
],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(2);
expect(state.allConversations[0].attachments).toContainEqual({
id: 1,
name: 'existing',
});
expect(state.allConversations[0].attachments).toContainEqual({
id: 2,
name: 'new',
});
});
it('should not add attachments if chat not found', () => {
const state = {
allConversations: [{ id: 1, attachments: [] }],
};
const message = {
conversation_id: 2,
status: 'sent',
attachments: [{ id: 1, name: 'test' }],
};
mutations[types.ADD_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(0);
});
});
describe('#DELETE_CONVERSATION_ATTACHMENTS', () => {
it('delete conversation attachments', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 1,
status: 'sent',
id: 1,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(0);
});
it('should not delete attachments for non-matching message id', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 1,
status: 'sent',
id: 2,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(1);
});
it('should not delete attachments if chat not found', () => {
const state = {
allConversations: [{ id: 1, attachments: [{ id: 1, message_id: 1 }] }],
};
const message = {
conversation_id: 2,
status: 'sent',
id: 1,
};
mutations[types.DELETE_CONVERSATION_ATTACHMENTS](state, message);
expect(state.allConversations[0].attachments).toHaveLength(1);
});
});
});
@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../../customViews';
import * as types from '../../../mutation-types';
import customViewList from './fixtures';
import { customViewList, updateCustomViewList } from './fixtures';
const commit = jest.fn();
global.axios = axios;
@@ -67,4 +67,28 @@ describe('#actions', () => {
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: updateCustomViewList[0] });
await actions.update(
{ commit },
updateCustomViewList[0].id,
updateCustomViewList[0]
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
[types.default.UPDATE_CUSTOM_VIEW, updateCustomViewList[0]],
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, 1)).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true }],
[types.default.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false }],
]);
});
});
});
@@ -1,4 +1,4 @@
export default [
export const customViewList = [
{
name: 'Custom view',
filter_type: 0,
@@ -34,3 +34,31 @@ export default [
},
},
];
export const updateCustomViewList = [
{
id: 1,
name: 'Open',
filter_type: 'conversation',
query: {
payload: [
{
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
custom_attribute_type: '',
},
{
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: [52],
custom_attribute_type: '',
},
],
},
created_at: '2022-02-08T03:17:38.761Z',
updated_at: '2023-06-05T13:57:48.478Z',
},
];
@@ -1,5 +1,5 @@
import { getters } from '../../customViews';
import customViewList from './fixtures';
import { customViewList } from './fixtures';
describe('#getters', () => {
it('getCustomViews', () => {
@@ -1,6 +1,6 @@
import types from '../../../mutation-types';
import { mutations } from '../../customViews';
import customViewList from './fixtures';
import { customViewList, updateCustomViewList } from './fixtures';
describe('#mutations', () => {
describe('#SET_CUSTOM_VIEW', () => {
@@ -26,4 +26,12 @@ describe('#mutations', () => {
expect(state.records).toEqual([customViewList[0]]);
});
});
describe('#UPDATE_CUSTOM_VIEW', () => {
it('update custom view record', () => {
const state = { records: [updateCustomViewList[0]] };
mutations[types.UPDATE_CUSTOM_VIEW](state, updateCustomViewList[0]);
expect(state.records).toEqual(updateCustomViewList);
});
});
});
@@ -49,6 +49,10 @@ export default {
UPDATE_CONVERSATION_LAST_ACTIVITY: 'UPDATE_CONVERSATION_LAST_ACTIVITY',
SET_MISSING_MESSAGES: 'SET_MISSING_MESSAGES',
SET_ALL_ATTACHMENTS: 'SET_ALL_ATTACHMENTS',
ADD_CONVERSATION_ATTACHMENTS: 'ADD_CONVERSATION_ATTACHMENTS',
DELETE_CONVERSATION_ATTACHMENTS: 'DELETE_CONVERSATION_ATTACHMENTS',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',
// Inboxes
@@ -217,6 +221,7 @@ export default {
SET_CUSTOM_VIEW_UI_FLAG: 'SET_CUSTOM_VIEW_UI_FLAG',
SET_CUSTOM_VIEW: 'SET_CUSTOM_VIEW',
ADD_CUSTOM_VIEW: 'ADD_CUSTOM_VIEW',
UPDATE_CUSTOM_VIEW: 'UPDATE_CUSTOM_VIEW',
DELETE_CUSTOM_VIEW: 'DELETE_CUSTOM_VIEW',
// Bulk Actions
+12 -3
View File
@@ -11,6 +11,7 @@ import {
hasUserKeys,
} from '../sdk/cookieHelpers';
import { addClasses, removeClasses } from '../sdk/DOMHelpers';
import { setCookieWithDomain } from '../sdk/cookieHelpers';
import { SDK_SET_BUBBLE_VISIBILITY } from 'shared/constants/sharedFrameEvents';
const runSDK = ({ baseUrl, websiteToken }) => {
if (window.$chatwoot) {
@@ -19,6 +20,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
const chatwootSettings = window.chatwootSettings || {};
let locale = chatwootSettings.locale;
let baseDomain = chatwootSettings.baseDomain;
if (chatwootSettings.useBrowserLanguage) {
locale = window.navigator.language.replace('-', '_');
@@ -26,6 +28,7 @@ const runSDK = ({ baseUrl, websiteToken }) => {
window.$chatwoot = {
baseUrl,
baseDomain,
hasLoaded: false,
hideMessageBubble: chatwootSettings.hideMessageBubble || false,
isOpen: false,
@@ -90,9 +93,9 @@ const runSDK = ({ baseUrl, websiteToken }) => {
window.$chatwoot.identifier = identifier;
window.$chatwoot.user = user;
IFrameHelper.sendMessage('set-user', { identifier, user });
Cookies.set(userCookieName, hashToBeStored, {
expires: 365,
sameSite: 'Lax',
setCookieWithDomain(userCookieName, hashToBeStored, {
baseDomain,
});
},
@@ -146,6 +149,12 @@ const runSDK = ({ baseUrl, websiteToken }) => {
IFrameHelper.sendMessage('set-locale', { locale: localeToBeUsed });
},
setColorScheme(darkMode = 'light') {
IFrameHelper.sendMessage('set-color-scheme', {
darkMode: getDarkMode(darkMode),
});
},
reset() {
if (window.$chatwoot.isOpen) {
IFrameHelper.events.toggleBubble();
+10 -11
View File
@@ -29,7 +29,7 @@ import {
CHATWOOT_READY,
} from '../widget/constants/sdkEvents';
import { SET_USER_ERROR } from '../widget/constants/errorTypes';
import { getUserCookieName } from './cookieHelpers';
import { getUserCookieName, setCookieWithDomain } from './cookieHelpers';
import {
getAlertAudio,
initOnEvents,
@@ -38,17 +38,16 @@ import { isFlatWidgetStyle } from './settingsHelper';
import { popoutChatWindow } from '../widget/helpers/popoutHelper';
import addHours from 'date-fns/addHours';
const updateAuthCookie = cookieContent =>
Cookies.set('cw_conversation', cookieContent, {
expires: 365,
sameSite: 'Lax',
const updateAuthCookie = (cookieContent, baseDomain = '') =>
setCookieWithDomain('cw_conversation', cookieContent, {
baseDomain,
});
const updateCampaignReadStatus = () => {
const updateCampaignReadStatus = baseDomain => {
const expireBy = addHours(new Date(), 1);
Cookies.set('cw_snooze_campaigns_till', Number(expireBy), {
setCookieWithDomain('cw_snooze_campaigns_till', Number(expireBy), {
expires: expireBy,
sameSite: 'Lax',
baseDomain,
});
};
@@ -154,7 +153,7 @@ export const IFrameHelper = {
events: {
loaded: message => {
updateAuthCookie(message.config.authToken);
updateAuthCookie(message.config.authToken, window.$chatwoot.baseDomain);
window.$chatwoot.hasLoaded = true;
const campaignsSnoozedTill = Cookies.get('cw_snooze_campaigns_till');
IFrameHelper.sendMessage('config-set', {
@@ -200,11 +199,11 @@ export const IFrameHelper = {
},
setAuthCookie({ data: { widgetAuthToken } }) {
updateAuthCookie(widgetAuthToken);
updateAuthCookie(widgetAuthToken, window.$chatwoot.baseDomain);
},
setCampaignReadOn() {
updateCampaignReadStatus();
updateCampaignReadStatus(window.$chatwoot.baseDomain);
},
toggleBubble: state => {
+1 -1
View File
@@ -1,3 +1,3 @@
export const BUBBLE_DESIGN = ['standard', 'expanded_bubble'];
export const WIDGET_DESIGN = ['standard', 'flat'];
export const DARK_MODE = ['light', 'auto'];
export const DARK_MODE = ['light', 'auto', 'dark'];
+15
View File
@@ -1,4 +1,5 @@
import md5 from 'md5';
import Cookies from 'js-cookie';
const REQUIRED_USER_KEYS = ['avatar_url', 'email', 'name'];
const ALLOWED_USER_ATTRIBUTES = [...REQUIRED_USER_KEYS, 'identifier_hash'];
@@ -21,3 +22,17 @@ export const computeHashForUserData = (...args) => md5(getUserString(...args));
export const hasUserKeys = user =>
REQUIRED_USER_KEYS.reduce((acc, key) => acc || !!user[key], false);
export const setCookieWithDomain = (
name,
value,
{ expires = 365, baseDomain = undefined } = {}
) => {
const cookieOptions = {
expires,
sameSite: 'Lax',
domain: baseDomain,
};
Cookies.set(name, value, cookieOptions);
};
@@ -1,7 +1,9 @@
import Cookies from 'js-cookie';
import {
getUserCookieName,
getUserString,
hasUserKeys,
setCookieWithDomain,
} from '../cookieHelpers';
describe('#getUserCookieName', () => {
@@ -47,3 +49,78 @@ describe('#hasUserKeys', () => {
expect(hasUserKeys({ avatar_url: 'randomValue' })).toBe(true);
});
});
// Mock the 'set' method of the 'Cookies' object
jest.mock('js-cookie', () => ({
set: jest.fn(),
}));
describe('setCookieWithDomain', () => {
afterEach(() => {
// Clear mock calls after each test
Cookies.set.mockClear();
});
it('should set a cookie with default parameters', () => {
setCookieWithDomain('myCookie', 'cookieValue');
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 365,
sameSite: 'Lax',
domain: undefined,
})
);
});
it('should set a cookie with custom expiration and sameSite attribute', () => {
setCookieWithDomain('myCookie', 'cookieValue', {
expires: 30,
});
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 30,
sameSite: 'Lax',
domain: undefined,
})
);
});
it('should set a cookie with a specific base domain', () => {
setCookieWithDomain('myCookie', 'cookieValue', {
baseDomain: 'example.com',
});
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 365,
sameSite: 'Lax',
domain: 'example.com',
})
);
});
it('should set a cookie with custom expiration, sameSite attribute, and specific base domain', () => {
setCookieWithDomain('myCookie', 'cookieValue', {
expires: 7,
baseDomain: 'example.com',
});
expect(Cookies.set).toHaveBeenCalledWith(
'myCookie',
'cookieValue',
expect.objectContaining({
expires: 7,
sameSite: 'Lax',
domain: 'example.com',
})
);
});
});
@@ -196,6 +196,10 @@
"M5 18C5 17.4477 5.44772 17 6 17H10C10.5523 17 11 17.4477 11 18V21C11 21.5523 10.5523 22 10 22H6C5.44772 22 5 21.5523 5 21V18Z",
"M13 18C13 17.4477 13.4477 17 14 17H18C18.5523 17 19 17.4477 19 18V21C19 21.5523 18.5523 22 18 22H14C13.4477 22 13 21.5523 13 21V18Z"
],
"preview-link-outline": [
"M4.524 6.25a.75.75 0 0 1 .75-.75H18.73a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-.75.75H5.274a.75.75 0 0 1-.75-.75v-3.5Zm1.5.75v2H17.98V7H6.024ZM14.23 11.979a.75.75 0 0 0-.75.75v4.5c0 .414.335.75.75.75h4.5a.75.75 0 0 0 .75-.75v-4.5a.75.75 0 0 0-.75-.75h-4.5Zm.75 4.5v-3h3v3h-3ZM4.524 13.25a.75.75 0 0 1 .75-.75h5.976a.75.75 0 0 1 0 1.5H5.274a.75.75 0 0 1-.75-.75ZM5.274 16a.75.75 0 0 0 0 1.5h5.976a.75.75 0 0 0 0-1.5H5.274Z",
"M2 5.75A2.75 2.75 0 0 1 4.75 3h14.5A2.75 2.75 0 0 1 22 5.75v12.5A2.75 2.75 0 0 1 19.25 21H4.75A2.75 2.75 0 0 1 2 18.25V5.75ZM4.75 4.5c-.69 0-1.25.56-1.25 1.25v12.5c0 .69.56 1.25 1.25 1.25h14.5c.69 0 1.25-.56 1.25-1.25V5.75c0-.69-.56-1.25-1.25-1.25H4.75Z"
],
"priority-urgent-outline": [
"M2.33325 2.91667C2.33325 2.27233 2.85559 1.75 3.49992 1.75C4.14425 1.75 4.66659 2.27233 4.66659 2.91667V8.16667C4.66659 8.811 4.14425 9.33333 3.49992 9.33333C2.85559 9.33333 2.33325 8.811 2.33325 8.16667V2.91667Z",
"M2.33325 11.0833C2.33325 10.439 2.85559 9.91667 3.49992 9.91667C4.14425 9.91667 4.66659 10.439 4.66659 11.0833C4.66659 11.7277 4.14425 12.25 3.49992 12.25C2.85559 12.25 2.33325 11.7277 2.33325 11.0833Z",
@@ -94,6 +94,14 @@ export const hasPressedArrowDownKey = e => {
return e.keyCode === 40;
};
export const hasPressedArrowLeftKey = e => {
return e.keyCode === 37;
};
export const hasPressedArrowRightKey = e => {
return e.keyCode === 39;
};
export const hasPressedCommandPlusKKey = e => {
return e.metaKey && e.keyCode === 75;
};
@@ -4,8 +4,9 @@ export const LocalStorage = {
},
get(key) {
const value = window.localStorage.getItem(key);
let value = null;
try {
value = window.localStorage.getItem(key);
return typeof value === 'string' ? JSON.parse(value) : value;
} catch (error) {
return value;
+12
View File
@@ -2,6 +2,7 @@
<div
v-if="!conversationSize && isFetchingList"
class="flex flex-1 items-center h-full bg-black-25 justify-center"
:class="{ dark: prefersDarkMode }"
>
<spinner size="" />
</div>
@@ -13,6 +14,7 @@
'is-widget-right': isRightAligned,
'is-bubble-hidden': hideMessageBubble,
'is-flat-design': isWidgetStyleFlat,
dark: prefersDarkMode,
}"
>
<router-view />
@@ -65,6 +67,7 @@ export default {
isFetchingList: 'conversation/getIsFetchingList',
isRightAligned: 'appConfig/isRightAligned',
isWidgetOpen: 'appConfig/getIsWidgetOpen',
darkMode: 'appConfig/darkMode',
messageCount: 'conversation/getMessageCount',
unreadMessageCount: 'conversation/getUnreadMessageCount',
isWidgetStyleFlat: 'appConfig/isWidgetStyleFlat',
@@ -75,6 +78,12 @@ export default {
isRNWebView() {
return RNHelper.isRNWebView();
},
prefersDarkMode() {
const isOSOnDarkMode =
this.darkMode === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
return isOSOnDarkMode || this.darkMode === 'dark';
},
},
watch: {
activeCampaign() {
@@ -108,6 +117,7 @@ export default {
'setReferrerHost',
'setWidgetColor',
'setBubbleVisibility',
'setColorScheme',
]),
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
...mapActions('campaign', [
@@ -298,6 +308,8 @@ export default {
} else if (message.event === 'set-locale') {
this.setLocale(message.locale);
this.setBubbleLabel();
} else if (message.event === 'set-color-scheme') {
this.setColorScheme(message.darkMode);
} else if (message.event === 'toggle-open') {
this.$store.dispatch('appConfig/toggleWidgetOpen', message.isOpen);
@@ -42,11 +42,10 @@
</div>
<p
v-if="message.showAvatar || hasRecordedResponse"
v-dompurify-html="agentName"
class="agent-name"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ agentName }}
</p>
/>
</div>
</div>
@@ -60,7 +60,7 @@ export default {
isAgentTyping: 'conversation/getIsAgentTyping',
}),
colorSchemeClass() {
return `${this.darkMode === 'light' ? 'light' : 'dark'}`;
return `${this.darkMode === 'dark' ? 'dark-scheme' : 'light-scheme'}`;
},
},
watch: {
@@ -117,10 +117,10 @@ export default {
overflow-y: auto;
color-scheme: light dark;
&.light {
&.light-scheme {
color-scheme: light;
}
&.dark {
&.dark-scheme {
color-scheme: dark;
}
}
@@ -9,6 +9,9 @@ export default {
if (this.darkMode === 'light') {
return light;
}
if (this.darkMode === 'dark') {
return dark;
}
return light + ' ' + dark;
},
},

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