Compare commits

..
Author SHA1 Message Date
Muhsin Keloth e66c706ff0 Ad example 2023-05-30 09:35:48 +05:30
Muhsin Keloth c3e90d4cbc Fix custom attribute date value is not working properly 2023-05-29 00:22:19 -07:00
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
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 JoseandGitHub 7b9a9831c1 chore: Fix rubococop failures (#7130)
- fixes rubocop failures on develop
2023-05-19 15:32:25 +05:30
Sojan JoseandGitHub 7ab7bac6bf chore: Enable the new Rubocop rules (#7122)
fixes: https://linear.app/chatwoot/issue/CW-1574/renable-the-disabled-rubocop-rules
2023-05-19 14:37:10 +05:30
Shivam MishraandGitHub b988a01df3 fix: exclude private and activity messages from public API (#7123)
- Exclude private messages
2023-05-19 14:24:49 +05:30
105f9a27d2 feat: more CSAT filters (#7038)
* refactor: use grid instead of flex

* refactor: let the parent layout decide the spacing

* feat: add a separate date-range component

* refactor: use new date-range component

* fix: destructure all options

* refactor: separate group by component

* refactor: better handle group by data

* fix: defaul group by

* refactor: variable naming

* refactor: use DATE_RANGE_OPTIONS directly

* chore: update platform in gemfile.lock

* refactor: trigger fetch on filter change

* refactor: remove redundant method

* refactor: simplify methods and emitting

* refactor: simplify filter logic

* refactor: simplify fetching

* refactor: imports

* refactor: prop name

* refactor: CSAT response to use new APIs

* refactor: use common filter event

* refactor: use computed value for validGroupBy

* refactor: better function names

* refactor: rename prop

* refactor: remove redundant props

* refactor: separate agents filter component

* feat: add labels filter

* feat: add inboxes filter

* fix: event

* refactor: send label and inbox along with request payload

* feat: add inbox filter

* feat: add inbox to download

* refactor: use request payload from computed property

* refactor: params

* feat: add team to csat filters

* feat: add team to csat filters

* feat: add filter for rating

* feat: reverse options

* feat: add labels for ratings and translations

* feat: update translation

* fix: margin and spacing

* fix: trailing whitespace

* feat: add tests for filters

* chore: move files

* feat: add try catch with alerts

* feat: update import

* fix: imports

* Updates broken imports

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-05-18 22:50:46 +05:30
132cf802d5 fix: Open AI Integration should respond in conversation language (#7092)
Appended "Reply in the user's language." to obtain the answer in the user's language.
Fixes: https://linear.app/chatwoot/issue/CW-1735/chore-use-account-language-for-the-prompts


Co-authored-by: Sojan <sojan@pepalo.com>
2023-05-18 18:50:48 +05:30
Shivam MishraandGitHub f2f33038f2 fix: Make category non-mandatory (#7117)
Creating articles in a new help center without categories throws an error. This PR fixes that.
2023-05-18 18:42:28 +05:30
Vishnu NarayananandGitHub 590ce788b9 fix: update linux script(cwctl) to account for rails7 upgrade (#7106)
* chore: modify cwctl to fetch latest redis version on new installations

* fix: add libvips for activestorage imageprocessing support

* chore: update cwctl version

* feat: upgrade redis and install libvps for existing installations
2023-05-18 16:53:47 +05:30
Clairton Rodrigo HeinzenandGitHub a25179c342 fix: Instagram events job NoMethodError: undefined method for nil:NilClass (#7105)
- Sometimes tag entry does not have messaging or standby and cause a error
2023-05-18 15:22:01 +05:30
Sivin VargheseandGitHub aea9470b6a fix: RTL issue for basic filter dropdown (#7118) 2023-05-18 15:06:18 +05:30
Muhsin KelothandGitHub 6382707146 chore: Check assignee exists or not before rendering the assignee avatar (#7104) 2023-05-17 15:41:23 +05:30
Pranav Raj SandGitHub 49ef4e54ca feat: Add cmd/ctrl click open on the conversation cards (#7100) 2023-05-16 16:57:25 -07:00
Sojan db0d32f01d Merge branch 'release/2.17.0' into develop 2023-05-16 12:34:00 +05:30
339 changed files with 4090 additions and 1019 deletions
+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
+23 -31
View File
@@ -162,41 +162,33 @@ Rails/RenderInline:
Performance/CollectionLiteralInLoop:
Exclude:
- 'db/migrate/20210315101919_enable_email_channel.rb'
RSpec/NamedSubject:
Enabled: false
Style/RedundantConstantBase:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
RSpec/Rails/MinitestAssertions:
Enabled: false
RSpec/Rails/InferredSpecType:
Enabled: false
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20200509044639_add_hide_input_flag_to_bot_config.rb'
- 'db/migrate/20200605130625_agent_away_message_to_auto_reply.rb'
- 'db/migrate/20200606132552_create_labels.rb'
- 'db/migrate/20201027135006_create_working_hours.rb'
- 'db/migrate/20210112174124_add_hmac_token_to_inbox.rb'
- 'db/migrate/20210114202310_create_teams.rb'
- 'db/migrate/20210212154240_add_request_for_email_on_channel_web_widget.rb'
- 'db/migrate/20210428135041_add_campaigns.rb'
- 'db/migrate/20210602182058_add_hmac_to_api_channel.rb'
- 'db/migrate/20210609133433_add_email_collect_to_inboxes.rb'
- 'db/migrate/20210618095823_add_csat_toggle_for_inbox.rb'
- 'db/migrate/20210927062350_add_trigger_only_during_business_hours_collect_to_campaigns.rb'
- 'db/migrate/20211027073553_add_imap_smtp_config_to_channel_email.rb'
- 'db/migrate/20211109143122_add_tweet_enabled_flag_to_twitter_channel.rb'
- 'db/migrate/20211216110209_add_allow_messages_after_resolved_to_inbox.rb'
- 'db/migrate/20220116103902_add_open_ssl_verify_mode_to_channel_email.rb'
- 'db/migrate/20220216151613_add_open_all_day_to_working_hour.rb'
- 'db/migrate/20220511072655_add_archive_column_to_portal.rb'
- 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet:
Enabled: false
RSpec/MatchArray:
Enabled: false
Rails/ResponseParsedBody:
Enabled: false
RSpec/FactoryBot/ConsistentParenthesesStyle:
Enabled: false
Rails/ThreeStateBooleanColumn:
Enabled: false
Rails/Pluck:
Enabled: false
Rails/TopLevelHashWithIndifferentAccess:
Enabled: false
Rails/ActionOrder:
Enabled: false
Style/ArrayIntersect:
Enabled: false
RSpec/NoExpectationExample:
Enabled: false
Style/RedundantReturn:
Enabled: false
Rails/I18nLocaleTexts:
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleMemoizedHelpers:
Max: 14
+1
View File
@@ -785,6 +785,7 @@ GEM
PLATFORMS
arm64-darwin-20
arm64-darwin-21
arm64-darwin-22
ruby
x86_64-darwin-18
x86_64-darwin-20
+1 -1
View File
@@ -1 +1 @@
2.2.0
2.17.0
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.3.0
@@ -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' }
@@ -16,6 +16,9 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
end
def show; end
def edit; end
def create
@article = @portal.articles.create!(article_params)
@article.associate_root_article(article_params[:associated_article_id])
@@ -23,10 +26,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
def edit; end
def show; end
def update
@article.update!(article_params) if params[:article].present?
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
@@ -6,6 +6,8 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rules = Current.account.automation_rules
end
def show; end
def create
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
@@ -28,8 +30,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def show; end
def update
ActiveRecord::Base.transaction do
automation_rule_update
@@ -6,21 +6,21 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
@campaigns = Current.account.campaigns
end
def show; end
def create
@campaign = Current.account.campaigns.create!(campaign_params)
end
def update
@campaign.update!(campaign_params)
end
def destroy
@campaign.destroy!
head :ok
end
def show; end
def update
@campaign.update!(campaign_params)
end
private
def campaign
@@ -9,6 +9,8 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@categories = @portal.categories.search(params)
end
def show; end
def create
@category = @portal.categories.create!(category_params)
@category.related_categories << related_categories_records
@@ -17,8 +19,6 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
@category.save!
end
def show; end
def update
@category.update!(category_params)
@category.related_categories = related_categories_records if related_categories_records.any?
@@ -5,21 +5,21 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
@notes = @contact.notes.latest.includes(:user)
end
def show; end
def create
@note = @contact.notes.create!(note_params)
end
def update
@note.update(note_params)
end
def destroy
@note.destroy!
head :ok
end
def show; end
def update
@note.update(note_params)
end
private
def note
@@ -26,6 +26,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@attachments = @conversation.attachments
end
def show; end
def create
ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
@@ -33,8 +35,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
end
def show; end
def filter
result = ::Conversations::FilterService.new(params.permit!, current_user).perform
@conversations = result[:conversations]
@@ -34,9 +34,12 @@ class Api::V1::Accounts::CsatSurveyResponsesController < Api::V1::Accounts::Base
end
def set_csat_survey_responses
@csat_survey_responses = filtrate(
Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
).filter_by_created_at(range).filter_by_assigned_agent_id(params[:user_ids])
base_query = Current.account.csat_survey_responses.includes([:conversation, :assigned_agent, :contact])
@csat_survey_responses = filtrate(base_query).filter_by_created_at(range)
.filter_by_assigned_agent_id(params[:user_ids])
.filter_by_inbox_id(params[:inbox_id])
.filter_by_team_id(params[:team_id])
.filter_by_rating(params[:rating])
end
def set_current_page_surveys
@@ -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
@@ -2,6 +2,11 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
before_action :fetch_inbox
before_action :current_agents_ids, only: [:create, :update]
def show
authorize @inbox, :show?
fetch_updated_agents
end
def create
authorize @inbox, :create?
ActiveRecord::Base.transaction do
@@ -10,11 +15,6 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
fetch_updated_agents
end
def show
authorize @inbox, :show?
fetch_updated_agents
end
def update
authorize @inbox, :update?
update_agents_list
@@ -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
@@ -6,6 +6,10 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macros = Macro.with_visibility(current_user, params)
end
def show
head :not_found if @macro.nil?
end
def create
@macro = Current.account.macros.new(macros_with_user.merge(created_by_id: current_user.id))
@macro.set_visibility(current_user, permitted_params)
@@ -18,8 +22,16 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
@macro
end
def show
head :not_found if @macro.nil?
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@@ -37,18 +49,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
render json: { error: @macro.errors.messages }.to_json, status: :unprocessable_entity
end
end
def execute
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
@@ -14,6 +14,11 @@ class Api::V1::AccountsController < Api::BaseController
CustomExceptions::Account::UserErrors,
with: :render_error_response
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def create
@user, @account = AccountBuilder.new(
account_name: account_params[:account_name],
@@ -35,11 +40,6 @@ class Api::V1::AccountsController < Api::BaseController
render json: { cache_keys: get_cache_keys }, status: :ok
end
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
end
def update
@account.update!(account_params.slice(:name, :locale, :domain, :support_email, :auto_resolve_duration))
end
@@ -4,6 +4,16 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
skip_before_action :require_no_authentication, raise: false
skip_before_action :authenticate_user!, raise: false
def create
@user = User.find_by(email: params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
def update
# params: reset_password_token, password, password_confirmation
original_token = params[:reset_password_token]
@@ -17,16 +27,6 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
end
end
def create
@user = User.find_by(email: params[:email])
if @user
@user.send_reset_password_instructions
build_response(I18n.t('messages.reset_password_success'), 200)
else
build_response(I18n.t('messages.reset_password_failure'), 404)
end
end
private
def reset_password_and_confirmation(recoverable)
@@ -1,4 +1,4 @@
class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsController
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -37,3 +37,5 @@ class DeviseOverrides::SessionsController < ::DeviseTokenAuth::SessionsControlle
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
@@ -1,4 +1,4 @@
class DeviseOverrides::TokenValidationsController < ::DeviseTokenAuth::TokenValidationsController
class DeviseOverrides::TokenValidationsController < DeviseTokenAuth::TokenValidationsController
def validate_token
# @resource will have been set by set_user_by_token concern
if @resource
@@ -1,12 +1,12 @@
class Platform::Api::V1::AccountsController < PlatformController
def show; end
def create
@resource = Account.create!(account_params)
update_resource_features
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.assign_attributes(account_params)
update_resource_features
@@ -6,14 +6,14 @@ class Platform::Api::V1::AgentBotsController < PlatformController
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end
def show; end
def create
@resource = AgentBot.new(agent_bot_params)
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.update!(agent_bot_params)
end
@@ -5,6 +5,8 @@ class Platform::Api::V1::UsersController < PlatformController
before_action(only: [:login]) { set_resource }
before_action(only: [:login]) { validate_platform_app_permissible }
def show; end
def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation!
@@ -16,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link }
end
def show; end
def update
@resource.assign_attributes(user_update_params)
@@ -2,6 +2,8 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
before_action :contact_inbox, except: [:create]
before_action :process_hmac
def show; end
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactInboxWithContactBuilder.new(
@@ -11,8 +13,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
).perform
end
def show; end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact_inbox.contact,
@@ -45,13 +45,17 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
def seed
Internal::SeedAccountJob.perform_later(requested_resource)
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
# rubocop:enable Rails/I18nLocaleTexts
end
def destroy
account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present?
# rubocop:disable Rails/I18nLocaleTexts
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
# rubocop:enable Rails/I18nLocaleTexts
end
end
+1 -1
View File
@@ -1,7 +1,7 @@
class SwaggerController < ApplicationController
def respond
if Rails.env.development? || Rails.env.test?
render inline: File.read(Rails.root.join('swagger', derived_path))
render inline: Rails.root.join('swagger', derived_path).read
else
head :not_found
end
+20
View File
@@ -28,6 +28,7 @@ import UpdateBanner from './components/app/UpdateBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer';
import rtlMixin from 'shared/mixins/rtlMixin';
import * as chrono from 'chrono-node';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -81,6 +82,25 @@ export default {
},
mounted() {
this.setLocale(window.chatwootConfig.selectedLocale);
console.log(
'An appointment on Sep 12-13unted --->',
chrono.parseDate('An appointment on Sep 12-13')
);
console.log('tomorrow --->', chrono.parseDate('tomorrow'));
console.log(
'tomorrow at 9.00AM --->',
chrono.parseDate('tomorrow at 9.00AM')
);
console.log('may 31 --->', chrono.parseDate('may 31'));
console.log('12.30 --->', chrono.parseDate('12.30'));
console.log('9.00AM --->', chrono.parseDate('9.00AM'));
console.log('tuesday --->', chrono.parseDate('tuesday'));
console.log('after 5 days --->', chrono.parseDate('after 5 days'));
console.log('tom 13.00 --->', chrono.parseDate('tom 13.00'));
console.log(
'next month 10.00AM --->',
chrono.parseDate('next month 10.00AM')
);
},
methods: {
setLocale(locale) {
+11 -4
View File
@@ -6,7 +6,7 @@ class CSATReportsAPI extends ApiClient {
super('csat_survey_responses', { accountScoped: true });
}
get({ page, from, to, user_ids } = {}) {
get({ page, from, to, user_ids, inbox_id, team_id, rating } = {}) {
return axios.get(this.url, {
params: {
page,
@@ -14,24 +14,31 @@ class CSATReportsAPI extends ApiClient {
until: to,
sort: '-created_at',
user_ids,
inbox_id,
team_id,
rating,
},
});
}
download({ from, to, user_ids } = {}) {
download({ from, to, user_ids, inbox_id, team_id, rating } = {}) {
return axios.get(`${this.url}/download`, {
params: {
since: from,
until: to,
sort: '-created_at',
user_ids,
inbox_id,
team_id,
rating,
},
});
}
getMetrics({ from, to, user_ids } = {}) {
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 },
params: { since: from, until: to, user_ids, inbox_id, team_id, rating },
});
}
}
@@ -5,12 +5,20 @@
}
.date-picker {
.mx-datepicker {
width: 100%;
&.no-margin {
.mx-input {
margin-bottom: 0;
}
}
.mx-datepicker-range {
width: 320px;
&:not(.auto-width) {
.mx-datepicker-range {
width: 320px;
}
}
.mx-datepicker {
width: 100%;
}
.mx-input {
@@ -261,6 +261,12 @@
}
}
// Basic filter dropdown
.basic-filter {
left: 0;
right: unset;
}
// Card label
.label-container {
.label {
@@ -13,7 +13,9 @@
}
.multiselect {
margin-bottom: var(--space-normal);
&:not(.no-margin) {
margin-bottom: var(--space-normal);
}
&.multiselect--disabled {
opacity: 0.8;
@@ -115,12 +115,10 @@
</template>
<script>
import format from 'date-fns/format';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import { isValidURL } from '../helper/URLHelper';
const DATE_FORMAT = 'yyyy-MM-dd';
export default {
components: {
@@ -144,9 +142,6 @@ export default {
computed: {
formattedValue() {
if (this.isAttributeTypeDate) {
return format(new Date(this.value || new Date()), DATE_FORMAT);
}
if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value;
}
@@ -193,8 +188,11 @@ export default {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
displayValue() {
if (this.attributeType === 'date') {
return format(new Date(this.editedValue), 'dd-MM-yyyy');
if (this.attributeType === 'date' && this.editedValue) {
return this.editedValue
.split('-')
.reverse()
.join('-');
}
return this.editedValue;
},
@@ -247,17 +245,12 @@ export default {
}
},
onUpdate() {
const updatedValue =
this.attributeType === 'date'
? format(new Date(this.editedValue), DATE_FORMAT)
: this.editedValue;
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.isEditing = false;
this.$emit('update', this.attributeKey, updatedValue);
this.$emit('update', this.attributeKey, this.editedValue);
},
onDelete() {
this.isEditing = false;
@@ -45,6 +45,14 @@ export default {
default: () => {},
},
},
watch: {
collection() {
this.renderChart(this.collection, {
...chartOptions,
...this.chartOptions,
});
},
},
mounted() {
this.renderChart(this.collection, {
...chartOptions,
@@ -9,7 +9,7 @@
}"
@mouseenter="onCardHover"
@mouseleave="onCardLeave"
@click="cardClick(chat)"
@click="onCardClick"
@contextmenu="openContextMenu($event)"
>
<label v-if="hovered || selected" class="checkbox-wrapper" @click.stop>
@@ -313,21 +313,33 @@ export default {
},
},
methods: {
cardClick(chat) {
const { activeInbox } = this;
const path = conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
});
onCardClick(e) {
const { activeInbox, chat } = this;
const path = frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
id: chat.id,
label: this.activeLabel,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
})
);
if (e.metaKey || e.ctrlKey) {
window.open(
window.chatwootConfig.hostURL + path,
'_blank',
'noopener noreferrer nofollow'
);
return;
}
if (this.isActiveChat) {
return;
}
router.push({ path: frontendURL(path) });
router.push({ path });
},
onCardHover() {
this.hovered = !this.hideThumbnail;
@@ -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"
@@ -200,7 +204,7 @@ export default {
hasImageError: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
hasStoryError: false,
hasImgStoryError: false,
};
},
computed: {
@@ -429,12 +433,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();
},
@@ -464,7 +468,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"
@@ -501,7 +501,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;
@@ -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"
/>
@@ -1,6 +1,13 @@
{
"CSAT": {
"TITLE": "Rate your conversation",
"PLACEHOLDER": "Tell us more..."
"PLACEHOLDER": "Tell us more...",
"RATINGS": {
"POOR": "😞 Poor",
"FAIR": "😑 Fair",
"AVERAGE": "😐 Average",
"GOOD": "😀 Good",
"EXCELLENT": "😍 Excellent"
}
}
}
@@ -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"
},
@@ -4,6 +4,8 @@
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversations",
@@ -34,6 +36,14 @@
"DESC": "( Total )"
}
},
"DATE_RANGE_OPTIONS": {
"LAST_7_DAYS": "Last 7 days",
"LAST_30_DAYS": "Last 30 days",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
"CUSTOM_DATE_RANGE": "Custom date range"
},
"DATE_RANGE": [
{
"id": 0,
@@ -66,6 +76,12 @@
},
"GROUP_BY_FILTER_DROPDOWN_LABEL": "Group By",
"DURATION_FILTER_LABEL": "Duration",
"GROUPING_OPTIONS": {
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Year"
},
"GROUP_BY_DAY_OPTIONS": [{ "id": 1, "groupBy": "Day" }],
"GROUP_BY_WEEK_OPTIONS": [
{ "id": 1, "groupBy": "Day" },
@@ -356,6 +372,7 @@
"HEADER": "CSAT Reports",
"NO_RECORDS": "There are no CSAT survey responses available.",
"DOWNLOAD": "Download CSAT Reports",
"DOWNLOAD_FAILED": "Failed to download CSAT Reports",
"FILTERS": {
"AGENTS": {
"PLACEHOLDER": "Choose Agents"
+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);
});
});
@@ -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, we’ve explained the features, capabilities, modes of operation, and step-by-step procedures for easily using the Chatwoot platform.`,
};
@@ -37,7 +37,7 @@
</div>
<div>
<thumbnail
v-if="notificationItem.primary_actor.meta.assignee"
v-if="hasAssignee(notificationItem)"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="16px"
:username="notificationItem.primary_actor.meta.assignee.name"
@@ -127,6 +127,9 @@ export default {
});
}
},
hasAssignee(notification) {
return notification.primary_actor.meta?.assignee;
},
},
};
</script>
@@ -37,7 +37,12 @@
{{ auditLogItem.remote_address }}
</td>
<td class="wrap-break-words">
{{ dynamicTime(auditLogItem.created_at) }}
{{
messageTimestamp(
auditLogItem.created_at,
'MMM dd, yyyy hh:mm a'
)
}}
</td>
</tr>
</tbody>
@@ -1,11 +1,12 @@
<template>
<div class="column content-box">
<report-filter-selector
agents-filter
:agents-filter-items-list="agentList"
:show-agents-filter="true"
:show-inbox-filter="true"
:show-rating-filter="true"
:show-team-filter="isTeamsEnabled"
:show-business-hours-switch="false"
@date-range-change="onDateRangeChange"
@agents-filter-change="onAgentsFilterChange"
@filter-change="onFilterChange"
/>
<woot-button
color-scheme="success"
@@ -15,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>
@@ -23,9 +24,11 @@
import CsatMetrics from './components/CsatMetrics';
import CsatTable from './components/CsatTable';
import ReportFilterSelector from './components/FilterSelector';
import { mapGetters } from 'vuex';
import { generateFileName } from '../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../../featureFlags';
import alertMixin from '../../../../../shared/mixins/alertMixin';
export default {
name: 'CsatResponses',
@@ -34,39 +37,78 @@ export default {
CsatTable,
ReportFilterSelector,
},
mixins: [alertMixin],
data() {
return { pageIndex: 1, from: 0, to: 0, userIds: [] };
return {
pageIndex: 1,
from: 0,
to: 0,
userIds: [],
inbox: null,
team: null,
rating: null,
};
},
computed: {
...mapGetters({
agentList: 'agents/getAgents',
accountId: 'getCurrentAccountId',
isFeatureEnabledOnAccount: 'accounts/isFeatureEnabledonAccount',
}),
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
getAllData() {
this.$store.dispatch('csat/getMetrics', {
requestPayload() {
return {
from: this.from,
to: this.to,
user_ids: this.userIds,
});
this.getResponses();
inbox_id: this.inbox,
team_id: this.team,
rating: this.rating,
};
},
isTeamsEnabled() {
return this.isFeatureEnabledOnAccount(
this.accountId,
FEATURE_FLAGS.TEAM_MANAGEMENT
);
},
},
methods: {
getAllData() {
try {
this.$store.dispatch('csat/getMetrics', this.requestPayload);
this.getResponses();
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
},
getResponses() {
this.$store.dispatch('csat/get', {
page: this.pageIndex,
from: this.from,
to: this.to,
user_ids: this.userIds,
...this.requestPayload,
});
},
downloadReports() {
const type = 'csat';
try {
this.$store.dispatch('csat/downloadCSATReports', {
fileName: generateFileName({ type, to: this.to }),
...this.requestPayload,
});
} catch (error) {
this.showAlert(this.$t('REPORT.CSAT_REPORTS.DOWNLOAD_FAILED'));
}
},
onPageNumberChange(pageIndex) {
this.pageIndex = pageIndex;
this.getResponses();
},
onDateRangeChange({ from, to }) {
onFilterChange({
from,
to,
selectedAgents,
selectedInbox,
selectedTeam,
selectedRating,
}) {
// do not track filter change on inital load
if (this.from !== 0 && this.to !== 0) {
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
@@ -74,27 +116,16 @@ export default {
reportType: 'csat',
});
}
this.from = from;
this.to = to;
this.userIds = selectedAgents.map(el => el.id);
this.inbox = selectedInbox?.id;
this.team = selectedTeam?.id;
this.rating = selectedRating?.value;
this.getAllData();
},
onAgentsFilterChange(agents) {
this.userIds = agents.map(el => el.id);
this.getAllData();
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'agent',
reportType: 'csat',
});
},
downloadReports() {
const type = 'csat';
this.$store.dispatch('csat/downloadCSATReports', {
from: this.from,
to: this.to,
user_ids: this.userIds,
fileName: generateFileName({ type, to: this.to }),
});
},
},
};
</script>
@@ -9,12 +9,9 @@
{{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }}
</woot-button>
<report-filter-selector
group-by-filter
:selected-group-by-filter="selectedGroupByFilter"
:filter-items-list="filterItemsList"
@date-range-change="onDateRangeChange"
:show-agents-filter="false"
:show-group-by-filter="true"
@filter-change="onFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/>
<div class="row">
<woot-report-stats-card
@@ -55,7 +52,8 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector';
import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
import reportMixin from '../../../../mixins/reportMixin';
import reportMixin from 'dashboard/mixins/reportMixin';
import alertMixin from 'shared/mixins/alertMixin';
import { formatTime } from '@chatwoot/utils';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -73,15 +71,13 @@ export default {
components: {
ReportFilterSelector,
},
mixins: [reportMixin],
mixins: [reportMixin, alertMixin],
data() {
return {
from: 0,
to: 0,
currentSelection: 0,
groupBy: GROUP_BY_FILTER[1],
filterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
selectedGroupByFilter: {},
businessHours: false,
};
},
@@ -96,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;
@@ -109,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');
@@ -191,24 +187,35 @@ export default {
},
methods: {
fetchAllData() {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountSummary', {
from,
to,
groupBy: groupBy.period,
businessHours,
});
this.fetchAccountSummary();
this.fetchChartData();
},
fetchAccountSummary() {
try {
this.$store.dispatch('fetchAccountSummary', this.getRequestPayload());
} catch {
this.showAlert(this.$t('REPORT.SUMMARY_FETCHING_FAILED'));
}
},
fetchChartData() {
try {
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
...this.getRequestPayload(),
});
} catch {
this.showAlert(this.$t('REPORT.DATA_FETCHING_FAILED'));
}
},
getRequestPayload() {
const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY,
return {
from,
to,
groupBy: groupBy.period,
groupBy: groupBy?.period,
businessHours,
});
};
},
downloadAgentReports() {
const { from, to } = this;
@@ -222,57 +229,15 @@ export default {
this.currentSelection = index;
this.fetchChartData();
},
onDateRangeChange({ from, to, groupBy }) {
// do not track filter change on inital load
if (this.from !== 0 && this.to !== 0) {
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'date',
reportType: 'conversations',
});
}
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;
this.to = to;
this.filterItemsList = this.fetchFilterItems(groupBy);
const filterItems = this.filterItemsList.filter(
item => item.id === this.groupBy.id
);
if (filterItems.length > 0) {
this.selectedGroupByFilter = filterItems[0];
} else {
this.selectedGroupByFilter = this.filterItemsList[0];
this.groupBy = GROUP_BY_FILTER[this.selectedGroupByFilter.id];
}
this.fetchAllData();
},
onFilterChange(payload) {
this.groupBy = GROUP_BY_FILTER[payload.id];
this.groupBy = groupBy;
this.businessHours = businessHours;
this.fetchAllData();
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'groupBy',
filterValue: this.groupBy?.period,
reportType: 'conversations',
});
},
fetchFilterItems(groupBy) {
switch (groupBy) {
case GROUP_BY_FILTER[2].period:
return this.$t('REPORT.GROUP_BY_WEEK_OPTIONS');
case GROUP_BY_FILTER[3].period:
return this.$t('REPORT.GROUP_BY_MONTH_OPTIONS');
case GROUP_BY_FILTER[4].period:
return this.$t('REPORT.GROUP_BY_YEAR_OPTIONS');
default:
return this.$t('REPORT.GROUP_BY_DAY_OPTIONS');
}
},
onBusinessHoursToggle(value) {
this.businessHours = value;
this.fetchAllData();
this.$track(REPORTS_EVENTS.FILTER_REPORT, {
filterType: 'businessHours',
filterValue: value,
filterValue: { from, to, groupBy, businessHours },
reportType: 'conversations',
});
},
@@ -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>
@@ -1,72 +1,43 @@
<template>
<div class="flex-container flex-dir-column medium-flex-dir-row">
<div class="small-12 medium-3 pull-right multiselect-wrap--small">
<multiselect
v-model="currentDateRangeSelection"
track-by="name"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="dateRange"
:searchable="false"
:allow-empty="false"
@select="changeDateSelection"
/>
</div>
<div class="filter-container">
<reports-filters-date-range @on-range-change="onDateRangeChange" />
<woot-date-range-picker
v-if="isDateRangeSelected"
class="margin-left-1"
show-range
class="no-margin auto-width"
:value="customDateRange"
:confirm-text="$t('REPORT.CUSTOM_DATE_RANGE.CONFIRM')"
:placeholder="$t('REPORT.CUSTOM_DATE_RANGE.PLACEHOLDER')"
@change="onChange"
@change="onCustomDateRangeChange"
/>
<div
v-if="notLast7Days && groupByFilter"
class="small-12 medium-3 pull-right margin-left-1 margin-right-1 multiselect-wrap--small"
>
<p aria-hidden="true" class="hide">
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
track-by="id"
label="groupBy"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="filterItemsList"
:allow-empty="false"
:show-labels="false"
@input="changeFilterSelection"
/>
</div>
<div
v-if="agentsFilter"
class="small-12 medium-3 pull-right margin-left-1 margin-right-1 multiselect-wrap--small"
>
<multiselect
v-model="selectedAgents"
:options="agentsFilterItemsList"
track-by="id"
label="name"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:hide-selected="true"
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@input="handleAgentsFilterSelection"
/>
</div>
<div
v-if="showBusinessHoursSwitch"
class="small-12 medium-3 business-hours"
>
<span class="business-hours-text margin-right-1">
<reports-filters-date-group-by
v-if="showGroupByFilter && isGroupByPossible"
:valid-group-options="validGroupOptions"
:selected-option="selectedGroupByFilter"
@on-grouping-change="onGroupingChange"
/>
<reports-filters-agents
v-if="showAgentsFilter"
@agents-filter-selection="handleAgentsFilterSelection"
/>
<reports-filters-labels
v-if="showLabelsFilter"
@labels-filter-selection="handleLabelsFilterSelection"
/>
<reports-filters-teams
v-if="showTeamFilter"
@team-filter-selection="handleTeamFilterSelection"
/>
<reports-filters-inboxes
v-if="showInboxFilter"
@inbox-filter-selection="handleInboxFilterSelection"
/>
<reports-filters-ratings
v-if="showRatingFilter"
@rating-filter-selection="handleRatingFilterSelection"
/>
<div v-if="showBusinessHoursSwitch" class="business-hours">
<span class="business-hours-text ">
{{ $t('REPORT.BUSINESS_HOURS') }}
</span>
<span>
@@ -77,36 +48,54 @@
</template>
<script>
import WootDateRangePicker from 'dashboard/components/ui/DateRangePicker.vue';
import ReportsFiltersDateRange from './Filters/DateRange.vue';
import ReportsFiltersDateGroupBy from './Filters/DateGroupBy.vue';
import ReportsFiltersAgents from './Filters/Agents.vue';
import ReportsFiltersLabels from './Filters/Labels.vue';
import ReportsFiltersInboxes from './Filters/Inboxes.vue';
import ReportsFiltersTeams from './Filters/Teams.vue';
import ReportsFiltersRatings from './Filters/Ratings.vue';
import subDays from 'date-fns/subDays';
import startOfDay from 'date-fns/startOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import { GROUP_BY_FILTER } from '../constants';
import endOfDay from 'date-fns/endOfDay';
const CUSTOM_DATE_RANGE_ID = 5;
import { DATE_RANGE_OPTIONS } from '../constants';
import { getUnixStartOfDay, getUnixEndOfDay } from 'helpers/DateHelper';
export default {
components: {
WootDateRangePicker,
ReportsFiltersDateRange,
ReportsFiltersDateGroupBy,
ReportsFiltersAgents,
ReportsFiltersLabels,
ReportsFiltersInboxes,
ReportsFiltersTeams,
ReportsFiltersRatings,
},
props: {
filterItemsList: {
type: Array,
default: () => [],
},
agentsFilterItemsList: {
type: Array,
default: () => [],
},
selectedGroupByFilter: {
type: Object,
default: () => {},
},
groupByFilter: {
showGroupByFilter: {
type: Boolean,
default: false,
},
agentsFilter: {
showAgentsFilter: {
type: Boolean,
default: false,
},
showLabelsFilter: {
type: Boolean,
default: false,
},
showInboxFilter: {
type: Boolean,
default: false,
},
showRatingFilter: {
type: Boolean,
default: false,
},
showTeamFilter: {
type: Boolean,
default: false,
},
@@ -117,95 +106,134 @@ export default {
},
data() {
return {
currentDateRangeSelection: this.$t('REPORT.DATE_RANGE')[0],
dateRange: this.$t('REPORT.DATE_RANGE'),
customDateRange: [new Date(), new Date()],
currentSelectedFilter: null,
// default value, need not be translated
selectedDateRange: DATE_RANGE_OPTIONS.LAST_7_DAYS,
selectedGroupByFilter: null,
selectedLabel: null,
selectedInbox: null,
selectedTeam: null,
selectedRating: null,
selectedAgents: [],
customDateRange: [new Date(), new Date()],
businessHoursSelected: false,
};
},
computed: {
isDateRangeSelected() {
return this.currentDateRangeSelection.id === CUSTOM_DATE_RANGE_ID;
return (
this.selectedDateRange.id === DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE.id
);
},
isGroupByPossible() {
return this.selectedDateRange.id !== DATE_RANGE_OPTIONS.LAST_7_DAYS.id;
},
to() {
if (this.isDateRangeSelected) {
return this.toCustomDate(this.customDateRange[1]);
return getUnixEndOfDay(this.customDateRange[1]);
}
return this.toCustomDate(new Date());
return getUnixEndOfDay(new Date());
},
from() {
if (this.isDateRangeSelected) {
return this.fromCustomDate(this.customDateRange[0]);
return getUnixStartOfDay(this.customDateRange[0]);
}
const dateRange = {
0: 6,
1: 29,
2: 89,
3: 179,
4: 364,
};
const diff = dateRange[this.currentDateRangeSelection.id];
const fromDate = subDays(new Date(), diff);
return this.fromCustomDate(fromDate);
const { offset } = this.selectedDateRange;
const fromDate = subDays(new Date(), offset);
return getUnixStartOfDay(fromDate);
},
groupBy() {
if (this.isDateRangeSelected) {
return GROUP_BY_FILTER[4].period;
validGroupOptions() {
return this.selectedDateRange.groupByOptions;
},
validGroupBy() {
if (!this.selectedGroupByFilter) {
return this.validGroupOptions[0];
}
const groupRange = {
0: GROUP_BY_FILTER[1].period,
1: GROUP_BY_FILTER[2].period,
2: GROUP_BY_FILTER[3].period,
3: GROUP_BY_FILTER[3].period,
4: GROUP_BY_FILTER[3].period,
};
return groupRange[this.currentDateRangeSelection.id];
},
notLast7Days() {
return this.groupBy !== GROUP_BY_FILTER[1].period;
const validIds = this.validGroupOptions.map(opt => opt.id);
if (validIds.includes(this.selectedGroupByFilter.id)) {
return this.selectedGroupByFilter;
}
return this.validGroupOptions[0];
},
},
watch: {
filterItemsList() {
this.currentSelectedFilter = this.selectedGroupByFilter;
},
businessHoursSelected() {
this.$emit('business-hours-toggle', this.businessHoursSelected);
this.emitChange();
},
},
mounted() {
this.onDateRangeChange();
this.emitChange();
},
methods: {
onDateRangeChange() {
this.$emit('date-range-change', {
from: this.from,
to: this.to,
groupBy: this.groupBy,
emitChange() {
const {
from,
to,
selectedGroupByFilter: groupBy,
businessHoursSelected: businessHours,
selectedAgents,
selectedLabel,
selectedInbox,
selectedTeam,
selectedRating,
} = this;
this.$emit('filter-change', {
from,
to,
groupBy,
businessHours,
selectedAgents,
selectedLabel,
selectedInbox,
selectedTeam,
selectedRating,
});
},
fromCustomDate(date) {
return getUnixTime(startOfDay(date));
onDateRangeChange(selectedRange) {
this.selectedDateRange = selectedRange;
this.selectedGroupByFilter = this.validGroupBy;
this.emitChange();
},
toCustomDate(date) {
return getUnixTime(endOfDay(date));
},
changeDateSelection(selectedRange) {
this.currentDateRangeSelection = selectedRange;
this.onDateRangeChange();
},
onChange(value) {
onCustomDateRangeChange(value) {
this.customDateRange = value;
this.onDateRangeChange();
this.selectedGroupByFilter = this.validGroupBy;
this.emitChange();
},
changeFilterSelection() {
this.$emit('filter-change', this.currentSelectedFilter);
onGroupingChange(payload) {
this.selectedGroupByFilter = payload;
this.emitChange();
},
handleAgentsFilterSelection() {
this.$emit('agents-filter-change', this.selectedAgents);
handleAgentsFilterSelection(selectedAgents) {
this.selectedAgents = selectedAgents;
this.emitChange();
},
handleLabelsFilterSelection(selectedLabel) {
this.selectedLabel = selectedLabel;
this.emitChange();
},
handleInboxFilterSelection(selectedInbox) {
this.selectedInbox = selectedInbox;
this.emitChange();
},
handleTeamFilterSelection(selectedTeam) {
this.selectedTeam = selectedTeam;
this.emitChange();
},
handleRatingFilterSelection(selectedRating) {
this.selectedRating = selectedRating;
this.emitChange();
},
},
};
</script>
<style scoped>
.filter-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
grid-gap: var(--space-slab);
margin-bottom: var(--space-normal);
}
</style>
@@ -0,0 +1,45 @@
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOptions"
class="no-margin"
:options="options"
track-by="id"
label="name"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:hide-selected="true"
:placeholder="$t('CSAT_REPORTS.FILTERS.AGENTS.PLACEHOLDER')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@input="handleInput"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersAgents',
data() {
return {
selectedOptions: [],
};
},
computed: {
...mapGetters({
options: 'agents/getAgents',
}),
},
mounted() {
this.$store.dispatch('agents/get');
},
methods: {
handleInput() {
this.$emit('agents-filter-selection', this.selectedOptions);
},
},
};
</script>
@@ -0,0 +1,67 @@
<template>
<div class="multiselect-wrap--small">
<p aria-hidden="true" class="hide">
{{ $t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL') }}
</p>
<multiselect
v-model="currentSelectedFilter"
class="no-margin"
track-by="id"
label="groupBy"
:placeholder="$t('REPORT.GROUP_BY_FILTER_DROPDOWN_LABEL')"
:options="translatedOptions"
:allow-empty="false"
:show-labels="false"
@select="changeFilterSelection"
/>
</div>
</template>
<script>
import { GROUP_BY_OPTIONS } from '../../constants';
const EVENT_NAME = 'on-grouping-change';
export default {
name: 'ReportsFiltersDateGroupBy',
props: {
validGroupOptions: {
type: Array,
default: () => [GROUP_BY_OPTIONS.DAY],
},
selectedOption: {
type: Object,
default: () => GROUP_BY_OPTIONS.DAY,
},
},
data() {
return {
currentSelectedFilter: null,
};
},
computed: {
translatedOptions() {
return this.validGroupOptions.map(option => ({
...option,
groupBy: this.$t(option.translationKey),
}));
},
},
watch: {
selectedOption: {
handler() {
this.currentSelectedFilter = {
...this.selectedOption,
groupBy: this.$t(this.selectedOption.translationKey),
};
},
immediate: true,
},
},
methods: {
changeFilterSelection(selectedFilter) {
this.groupByOptions = this.$emit(EVENT_NAME, selectedFilter);
},
},
};
</script>
@@ -0,0 +1,46 @@
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
track-by="name"
label="name"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:options="options"
:searchable="false"
:allow-empty="false"
@select="updateRange"
/>
</div>
</template>
<script>
import { DATE_RANGE_OPTIONS } from '../../constants';
const EVENT_NAME = 'on-range-change';
export default {
name: 'ReportFiltersDateRange',
data() {
const translatedOptions = Object.values(DATE_RANGE_OPTIONS).map(option => ({
...option,
name: this.$t(option.translationKey),
}));
return {
// relies on translations, need to move it to constants
selectedOption: translatedOptions[0],
options: translatedOptions,
};
},
methods: {
updateRange(selectedRange) {
this.selectedOption = selectedRange;
this.$emit(EVENT_NAME, selectedRange);
},
},
};
</script>
@@ -0,0 +1,40 @@
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:placeholder="$t('INBOX_REPORTS.FILTER_DROPDOWN_LABEL')"
label="name"
track-by="id"
:options="options"
:option-height="24"
:show-labels="false"
@input="handleInput"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersInboxes',
data() {
return {
selectedOption: null,
};
},
computed: {
...mapGetters({
options: 'inboxes/getInboxes',
}),
},
mounted() {
this.$store.dispatch('inboxes/get');
},
methods: {
handleInput() {
this.$emit('inbox-filter-selection', this.selectedOption);
},
},
};
</script>
@@ -0,0 +1,71 @@
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:placeholder="$t('LABEL_REPORTS.FILTER_DROPDOWN_LABEL')"
label="title"
track-by="id"
:options="options"
:option-height="24"
:show-labels="false"
@input="handleInput"
>
<template slot="singleLabel" slot-scope="props">
<div class="reports-option__wrap">
<div
:style="{ backgroundColor: props.option.color }"
class="reports-option__rounded--item"
/>
<span class="reports-option__desc">
<span class="reports-option__title">
{{ props.option.title }}
</span>
</span>
</div>
</template>
<template slot="option" slot-scope="props">
<div class="reports-option__wrap">
<div
:style="{ backgroundColor: props.option.color }"
class="
reports-option__rounded--item
reports-option__item
reports-option__label--swatch
"
/>
<span class="reports-option__desc">
<span class="reports-option__title">
{{ props.option.title }}
</span>
</span>
</div>
</template>
</multiselect>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersLabels',
data() {
return {
selectedOption: null,
};
},
computed: {
...mapGetters({
options: 'labels/getLabels',
}),
},
mounted() {
this.$store.dispatch('labels/get');
},
methods: {
handleInput() {
this.$emit('labels-filter-selection', this.selectedOption);
},
},
};
</script>
@@ -0,0 +1,39 @@
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:option-height="24"
:placeholder="$t('FORMS.MULTISELECT.SELECT_ONE')"
:options="options"
:show-labels="false"
track-by="value"
label="label"
@input="handleInput"
/>
</div>
</template>
<script>
import { CSAT_RATINGS } from 'shared/constants/messages';
export default {
name: 'ReportFiltersRatings',
data() {
const translatedOptions = CSAT_RATINGS.reverse().map(option => ({
...option,
label: this.$t(option.translationKey),
}));
return {
selectedOption: null,
options: translatedOptions,
};
},
methods: {
handleInput(selectedRating) {
this.$emit('rating-filter-selection', selectedRating);
},
},
};
</script>
@@ -0,0 +1,40 @@
<template>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedOption"
class="no-margin"
:placeholder="$t('TEAM_REPORTS.FILTER_DROPDOWN_LABEL')"
label="name"
track-by="id"
:options="options"
:option-height="24"
:show-labels="false"
@input="handleInput"
/>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
name: 'ReportsFiltersTeams',
data() {
return {
selectedOption: null,
};
},
computed: {
...mapGetters({
options: 'teams/getTeams',
}),
},
mounted() {
this.$store.dispatch('teams/get');
},
methods: {
handleInput() {
this.$emit('team-filter-selection', this.selectedOption);
},
},
};
</script>
@@ -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,59 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ReportsFiltersAgents from '../../Filters/Agents';
const localVue = createLocalVue();
localVue.use(Vuex);
const mockStore = new Vuex.Store({
modules: {
agents: {
namespaced: true,
state: {
agents: [],
},
getters: {
getAgents: state => state.agents,
},
actions: {
get: jest.fn(),
},
},
},
});
const mountParams = {
localVue,
store: mockStore,
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
describe('ReportsFiltersAgents.vue', () => {
it('emits "agents-filter-selection" event when handleInput is called', () => {
const wrapper = shallowMount(ReportsFiltersAgents, mountParams);
const selectedAgents = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
wrapper.setData({ selectedOptions: selectedAgents });
wrapper.vm.handleInput();
expect(wrapper.emitted('agents-filter-selection')).toBeTruthy();
expect(wrapper.emitted('agents-filter-selection')[0]).toEqual([
selectedAgents,
]);
});
it('dispatches the "agents/get" action when the component is mounted', () => {
const dispatchSpy = jest.spyOn(mockStore, 'dispatch');
shallowMount(ReportsFiltersAgents, mountParams);
expect(dispatchSpy).toHaveBeenCalledWith('agents/get');
});
});
@@ -0,0 +1,45 @@
import { shallowMount } from '@vue/test-utils';
import ReportsFiltersDateGroupBy from '../../Filters/DateGroupBy';
import { GROUP_BY_OPTIONS } from '../../../constants';
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
describe('ReportsFiltersDateGroupBy.vue', () => {
it('emits "on-grouping-change" event when changeFilterSelection is called', () => {
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
const selectedFilter = GROUP_BY_OPTIONS.DAY;
wrapper.vm.changeFilterSelection(selectedFilter);
expect(wrapper.emitted('on-grouping-change')).toBeTruthy();
expect(wrapper.emitted('on-grouping-change')[0]).toEqual([selectedFilter]);
});
it('updates currentSelectedFilter when selectedOption is changed', async () => {
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
const newSelectedOption = GROUP_BY_OPTIONS.MONTH;
await wrapper.setProps({ selectedOption: newSelectedOption });
expect(wrapper.vm.currentSelectedFilter).toEqual({
...newSelectedOption,
groupBy: newSelectedOption.translationKey,
});
});
it('initializes translatedOptions correctly', () => {
const wrapper = shallowMount(ReportsFiltersDateGroupBy, mountParams);
const expectedOptions = wrapper.vm.validGroupOptions.map(option => ({
...option,
groupBy: option.translationKey,
}));
expect(wrapper.vm.translatedOptions).toEqual(expectedOptions);
});
});
@@ -0,0 +1,42 @@
import { shallowMount } from '@vue/test-utils';
import ReportFiltersDateRange from '../../Filters/DateRange';
import { DATE_RANGE_OPTIONS } from '../../../constants';
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
describe('ReportFiltersDateRange.vue', () => {
it('emits "on-range-change" event when updateRange is called', () => {
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
const selectedRange = DATE_RANGE_OPTIONS.LAST_7_DAYS;
wrapper.vm.updateRange(selectedRange);
expect(wrapper.emitted('on-range-change')).toBeTruthy();
expect(wrapper.emitted('on-range-change')[0]).toEqual([selectedRange]);
});
it('initializes options correctly', () => {
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
const expectedOptions = Object.values(DATE_RANGE_OPTIONS).map(option => ({
...option,
name: option.translationKey,
}));
expect(wrapper.vm.options).toEqual(expectedOptions);
});
it('initializes selectedOption correctly', () => {
const wrapper = shallowMount(ReportFiltersDateRange, mountParams);
const expectedSelectedOption = Object.values(DATE_RANGE_OPTIONS)[0];
expect(wrapper.vm.selectedOption).toEqual({
...expectedSelectedOption,
name: expectedSelectedOption.translationKey,
});
});
});
@@ -0,0 +1,66 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ReportsFiltersInboxes from '../../Filters/Inboxes';
const localVue = createLocalVue();
localVue.use(Vuex);
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
describe('ReportsFiltersInboxes.vue', () => {
let store;
let inboxesModule;
beforeEach(() => {
inboxesModule = {
namespaced: true,
getters: {
getInboxes: () => () => [
{ id: 1, name: 'Inbox 1' },
{ id: 2, name: 'Inbox 2' },
],
},
actions: {
get: jest.fn(),
},
};
store = new Vuex.Store({
modules: {
inboxes: inboxesModule,
},
});
});
it('dispatches "inboxes/get" action when component is mounted', () => {
shallowMount(ReportsFiltersInboxes, {
store,
localVue,
...mountParams,
});
expect(inboxesModule.actions.get).toHaveBeenCalled();
});
it('emits "inbox-filter-selection" event when handleInput is called', () => {
const wrapper = shallowMount(ReportsFiltersInboxes, {
store,
localVue,
...mountParams,
});
const selectedInbox = { id: 1, name: 'Inbox 1' };
wrapper.setData({ selectedOption: selectedInbox });
wrapper.vm.handleInput();
expect(wrapper.emitted('inbox-filter-selection')).toBeTruthy();
expect(wrapper.emitted('inbox-filter-selection')[0]).toEqual([
selectedInbox,
]);
});
});
@@ -0,0 +1,66 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ReportsFiltersLabels from '../../Filters/Labels';
const localVue = createLocalVue();
localVue.use(Vuex);
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
describe('ReportsFiltersLabels.vue', () => {
let store;
let labelsModule;
beforeEach(() => {
labelsModule = {
namespaced: true,
getters: {
getLabels: () => () => [
{ id: 1, title: 'Label 1', color: 'red' },
{ id: 2, title: 'Label 2', color: 'blue' },
],
},
actions: {
get: jest.fn(),
},
};
store = new Vuex.Store({
modules: {
labels: labelsModule,
},
});
});
it('dispatches "labels/get" action when component is mounted', () => {
shallowMount(ReportsFiltersLabels, {
store,
localVue,
...mountParams,
});
expect(labelsModule.actions.get).toHaveBeenCalled();
});
it('emits "labels-filter-selection" event when handleInput is called', () => {
const wrapper = shallowMount(ReportsFiltersLabels, {
store,
localVue,
...mountParams,
});
const selectedLabel = { id: 1, title: 'Label 1', color: 'red' };
wrapper.setData({ selectedOption: selectedLabel });
wrapper.vm.handleInput();
expect(wrapper.emitted('labels-filter-selection')).toBeTruthy();
expect(wrapper.emitted('labels-filter-selection')[0]).toEqual([
selectedLabel,
]);
});
});
@@ -0,0 +1,45 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import ReportFiltersRatings from '../../Filters/Ratings';
import { CSAT_RATINGS } from 'shared/constants/messages';
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
const localVue = createLocalVue();
describe('ReportFiltersRatings.vue', () => {
it('emits "rating-filter-selection" event when handleInput is called', () => {
const wrapper = shallowMount(ReportFiltersRatings, {
localVue,
...mountParams,
});
const selectedRating = { value: 1, label: 'Rating 1' };
wrapper.setData({ selectedOption: selectedRating });
wrapper.vm.handleInput(selectedRating);
expect(wrapper.emitted('rating-filter-selection')).toBeTruthy();
expect(wrapper.emitted('rating-filter-selection')[0]).toEqual([
selectedRating,
]);
});
it('initializes options correctly', () => {
const wrapper = shallowMount(ReportFiltersRatings, {
localVue,
...mountParams,
});
const expectedOptions = CSAT_RATINGS.map(option => ({
...option,
label: option.translationKey,
}));
expect(wrapper.vm.options).toEqual(expectedOptions);
});
});
@@ -0,0 +1,62 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import ReportsFiltersTeams from '../../Filters/Teams.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
const mountParams = {
mocks: {
$t: msg => msg,
},
stubs: ['multiselect'],
};
describe('ReportsFiltersTeams.vue', () => {
let store;
let teamsModule;
beforeEach(() => {
teamsModule = {
namespaced: true,
getters: {
getTeams: () => () => [
{ id: 1, name: 'Team 1' },
{ id: 2, name: 'Team 2' },
],
},
actions: {
get: jest.fn(),
},
};
store = new Vuex.Store({
modules: {
teams: teamsModule,
},
});
});
it('dispatches "teams/get" action when component is mounted', () => {
shallowMount(ReportsFiltersTeams, {
store,
localVue,
...mountParams,
});
expect(teamsModule.actions.get).toHaveBeenCalled();
});
it('emits "team-filter-selection" event when handleInput is called', () => {
const wrapper = shallowMount(ReportsFiltersTeams, {
store,
localVue,
...mountParams,
});
wrapper.setData({ selectedOption: { id: 1, name: 'Team 1' } });
wrapper.vm.handleInput();
expect(wrapper.emitted('team-filter-selection')).toBeTruthy();
expect(wrapper.emitted('team-filter-selection')[0]).toEqual([
{ id: 1, name: 'Team 1' },
]);
});
});
@@ -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>
`;
@@ -7,6 +7,85 @@ export const GROUP_BY_FILTER = {
4: { id: 4, period: 'year' },
};
export const GROUP_BY_OPTIONS = {
DAY: {
id: 'DAY',
period: 'day',
translationKey: 'REPORT.GROUPING_OPTIONS.DAY',
},
WEEK: {
id: 'WEEK',
period: 'week',
translationKey: 'REPORT.GROUPING_OPTIONS.WEEK',
},
MONTH: {
id: 'MONTH',
period: 'month',
translationKey: 'REPORT.GROUPING_OPTIONS.MONTH',
},
YEAR: {
id: 'YEAR',
period: 'year',
translationKey: 'REPORT.GROUPING_OPTIONS.YEAR',
},
};
export const DATE_RANGE_OPTIONS = {
LAST_7_DAYS: {
id: 'LAST_7_DAYS',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS',
offset: 6,
groupByOptions: [GROUP_BY_OPTIONS.DAY],
},
LAST_30_DAYS: {
id: 'LAST_30_DAYS',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS',
offset: 29,
groupByOptions: [GROUP_BY_OPTIONS.DAY, GROUP_BY_OPTIONS.WEEK],
},
LAST_3_MONTHS: {
id: 'LAST_3_MONTHS',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_3_MONTHS',
offset: 89,
groupByOptions: [
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
},
LAST_6_MONTHS: {
id: 'LAST_6_MONTHS',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_6_MONTHS',
offset: 179,
groupByOptions: [
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
},
LAST_YEAR: {
id: 'LAST_YEAR',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.LAST_YEAR',
offset: 364,
groupByOptions: [
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
],
},
CUSTOM_DATE_RANGE: {
id: 'CUSTOM_DATE_RANGE',
translationKey: 'REPORT.DATE_RANGE_OPTIONS.CUSTOM_DATE_RANGE',
offset: null,
groupByOptions: [
GROUP_BY_OPTIONS.DAY,
GROUP_BY_OPTIONS.WEEK,
GROUP_BY_OPTIONS.MONTH,
GROUP_BY_OPTIONS.YEAR,
],
},
};
export const CHART_FONT_FAMILY =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
@@ -85,13 +85,10 @@ export const getters = {
};
export const actions = {
get: async function getResponses(
{ commit },
{ page = 1, from, to, user_ids } = {}
) {
get: async function getResponses({ commit }, params) {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: true });
try {
const response = await CSATReports.get({ page, from, to, user_ids });
const response = await CSATReports.get(params);
commit(types.SET_CSAT_RESPONSE, response.data);
} catch (error) {
// Ignore error
@@ -99,10 +96,10 @@ export const actions = {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: false });
}
},
getMetrics: async function getMetrics({ commit }, { from, to, user_ids }) {
getMetrics: async function getMetrics({ commit }, params) {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: true });
try {
const response = await CSATReports.getMetrics({ from, to, user_ids });
const response = await CSATReports.getMetrics(params);
commit(types.SET_CSAT_RESPONSE_METRICS, response.data);
} catch (error) {
// Ignore error
+6
View File
@@ -146,6 +146,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();
+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'];
@@ -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",
@@ -59,24 +59,28 @@ export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
export const CSAT_RATINGS = [
{
key: 'disappointed',
translationKey: 'CSAT.RATINGS.POOR',
emoji: '😞',
value: 1,
color: '#FDAD2A',
},
{
key: 'expressionless',
translationKey: 'CSAT.RATINGS.FAIR',
emoji: '😑',
value: 2,
color: '#FFC532',
},
{
key: 'neutral',
translationKey: 'CSAT.RATINGS.AVERAGE',
emoji: '😐',
value: 3,
color: '#FCEC56',
},
{
key: 'grinning',
translationKey: 'CSAT.RATINGS.GOOD',
emoji: '😀',
value: 4,
color: '#6FD86F',
@@ -84,6 +88,7 @@ export const CSAT_RATINGS = [
{
key: 'smiling',
emoji: '😍',
translationKey: 'CSAT.RATINGS.EXCELLENT',
value: 5,
color: '#44CE4B',
},
+9 -1
View File
@@ -2,6 +2,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import isToday from 'date-fns/isToday';
import isYesterday from 'date-fns/isYesterday';
import { endOfDay, getUnixTime, startOfDay } from 'date-fns';
export const formatUnixDate = (date, dateFormat = 'MMM dd, yyyy') => {
const unixDate = fromUnixTime(date);
@@ -31,8 +32,15 @@ export const isTimeAfter = (h1, m1, h2, m2) => {
return true;
};
/** Get start of day as a UNIX timestamp */
export const getUnixStartOfDay = date => getUnixTime(startOfDay(date));
/** Get end of day as a UNIX timestamp */
export const getUnixEndOfDay = date => getUnixTime(endOfDay(date));
export const generateRelativeTime = (value, unit, languageCode) => {
const rtf = new Intl.RelativeTimeFormat(languageCode, {
const code = languageCode?.replace(/_/g, '-'); // Hacky fix we need to handle it from source
const rtf = new Intl.RelativeTimeFormat(code, {
numeric: 'auto',
});
return rtf.format(value, unit);
@@ -64,15 +64,59 @@ describe('#isTimeAfter', () => {
});
});
describe('#generateRelativeTime', () => {
it('should return correct relative time', () => {
expect(generateRelativeTime(-1, 'day', 'en')).toEqual('yesterday');
expect(generateRelativeTime(1, 'day', 'en')).toEqual('tomorrow');
expect(generateRelativeTime(1, 'hour', 'en')).toEqual('in 1 hour');
expect(generateRelativeTime(-1, 'hour', 'en')).toEqual('1 hour ago');
expect(generateRelativeTime(1, 'minute', 'en')).toEqual('in 1 minute');
expect(generateRelativeTime(-1, 'minute', 'en')).toEqual('1 minute ago');
expect(generateRelativeTime(1, 'second', 'en')).toEqual('in 1 second');
expect(generateRelativeTime(-1, 'second', 'en')).toEqual('1 second ago');
describe('generateRelativeTime', () => {
it('should return a string with the relative time', () => {
const value = 1;
const unit = 'second';
const languageCode = 'en-US';
const expectedResult = 'in 1 second';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should return a string with the relative time in a different language', () => {
const value = 10;
const unit = 'minute';
const languageCode = 'de-DE';
const expectedResult = 'in 10 Minuten';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should return a string with the relative time for a different unit', () => {
const value = 1;
const unit = 'hour';
const languageCode = 'en-US';
const expectedResult = 'in 1 hour';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should throw an error if the value is not a number', () => {
const value = 1;
const unit = 'day';
const languageCode = 'en_US';
const expectedResult = 'tomorrow';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
it('should throw an error if the value is not a number', () => {
const value = 1;
const unit = 'day';
const languageCode = 'en-US';
const expectedResult = 'tomorrow';
const actualResult = generateRelativeTime(value, unit, languageCode);
expect(actualResult).toBe(expectedResult);
});
});
+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);
@@ -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;
},
},
@@ -1,5 +1,6 @@
import {
SET_BUBBLE_VISIBILITY,
SET_COLOR_SCHEME,
SET_REFERRER_HOST,
SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR,
@@ -55,6 +56,9 @@ export const actions = {
setWidgetColor({ commit }, widgetColor) {
commit(SET_WIDGET_COLOR, widgetColor);
},
setColorScheme({ commit }, darkMode) {
commit(SET_COLOR_SCHEME, darkMode);
},
setReferrerHost({ commit }, referrerHost) {
commit(SET_REFERRER_HOST, referrerHost);
},
@@ -83,6 +87,9 @@ export const mutations = {
[SET_BUBBLE_VISIBILITY]($state, hideMessageBubble) {
$state.hideMessageBubble = hideMessageBubble;
},
[SET_COLOR_SCHEME]($state, darkMode) {
$state.darkMode = darkMode;
},
};
export default {
@@ -24,4 +24,11 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([['SET_WIDGET_COLOR', '#eaeaea']]);
});
});
describe('#setColorScheme', () => {
it('creates actions for dark mode properly', () => {
actions.setColorScheme({ commit }, 'dark');
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
});
});
});
@@ -24,4 +24,12 @@ describe('#mutations', () => {
expect(state.widgetColor).toEqual('#00bcd4');
});
});
describe('#SET_COLOR_SCHEME', () => {
it('sets dark mode properly', () => {
const state = { darkMode: 'light' };
mutations.SET_COLOR_SCHEME(state, 'dark');
expect(state.darkMode).toEqual('dark');
});
});
});
+1
View File
@@ -2,6 +2,7 @@ export const CLEAR_CONVERSATION_ATTRIBUTES = 'CLEAR_CONVERSATION_ATTRIBUTES';
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
export const SET_WIDGET_APP_CONFIG = 'SET_WIDGET_APP_CONFIG';
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME';
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
+5 -2
View File
@@ -20,8 +20,11 @@ class HookJob < ApplicationJob
return unless ['message.created'].include?(event_name)
message = event_data[:message]
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
if message.attachments.blank?
::SendOnSlackJob.perform_later(message, hook)
else
::SendOnSlackJob.set(wait: 2.seconds).perform_later(message, hook)
end
end
def process_dialogflow_integration(hook, event_name, event_data)
+3 -2
View File
@@ -7,9 +7,10 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
return unless should_fetch_email?(channel)
process_email_for_channel(channel)
rescue *ExceptionList::IMAP_EXCEPTIONS
rescue *ExceptionList::IMAP_EXCEPTIONS => e
Rails.logger.error e
channel.authorization_error!
rescue EOFError, OpenSSL::SSL::SSLError => e
rescue EOFError, OpenSSL::SSL::SSLError, Net::IMAP::NoResponseError => e
Rails.logger.error e
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
+7
View File
@@ -0,0 +1,7 @@
class SendOnSlackJob < ApplicationJob
queue_as :medium
def perform(message, hook)
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end
end
+1 -1
View File
@@ -31,6 +31,6 @@ class Webhooks::InstagramEventsJob < ApplicationJob
end
def messages(entry)
(entry[:messaging].presence || entry[:standby])
(entry[:messaging].presence || entry[:standby] || [])
end
end
+1 -1
View File
@@ -77,4 +77,4 @@ class AutomationRule < ApplicationRecord
end
end
AutomationRule.include_mod_with('Audit::Inbox')
AutomationRule.include_mod_with('Audit::AutomationRule')
+2 -2
View File
@@ -6,7 +6,7 @@ module Featurable
check_for_column: false
}.freeze
FEATURE_LIST = YAML.safe_load(File.read(Rails.root.join('config/features.yml'))).freeze
FEATURE_LIST = YAML.safe_load(Rails.root.join('config/features.yml').read).freeze
FEATURES = FEATURE_LIST.each_with_object({}) do |feature, result|
result[result.keys.size + 1] = "feature_#{feature['name']}".to_sym
@@ -46,7 +46,7 @@ module Featurable
end
def all_features
FEATURE_LIST.map { |f| f['name'] }.index_with do |feature_name|
FEATURE_LIST.pluck('name').index_with do |feature_name|
feature_enabled?(feature_name)
end
end
+10
View File
@@ -6,6 +6,16 @@ module Pubsubable
included do
# Used by the actionCable/PubSub Service we use for real time communications
has_secure_token :pubsub_token
before_save :rotate_pubsub_token
end
def rotate_pubsub_token
# ATM we are only rotating the token if the user is changing their password
return unless is_a?(User)
# Using the class method to avoid the extra Save
# TODO: Should we do this on signin ?
self.pubsub_token = self.class.generate_unique_secure_token if will_save_change_to_encrypted_password?
end
def pubsub_token
+2 -1
View File
@@ -19,6 +19,7 @@
# index_contacts_on_account_id (account_id)
# index_contacts_on_lower_email_account_id (lower((email)::text), account_id)
# index_contacts_on_name_email_phone_number_identifier (name,email,phone_number,identifier) USING gin
# index_contacts_on_identifiable_fields (account_id,email,phone_number,identifier) WHERE (((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text)) # rubocop:disable Layout/LineLength
# index_contacts_on_phone_number_and_account_id (phone_number,account_id)
# uniq_email_per_account_contact (email,account_id) UNIQUE
# uniq_identifier_per_account_contact (identifier,account_id) UNIQUE
@@ -136,7 +137,7 @@ class Contact < ApplicationRecord
end
def self.resolved_contacts
where("COALESCE(NULLIF(contacts.email,''),NULLIF(contacts.phone_number,''),NULLIF(contacts.identifier,'')) IS NOT NULL")
where("contacts.email <> '' OR contacts.phone_number <> '' OR contacts.identifier <> ''")
end
def discard_invalid_attrs
+2 -2
View File
@@ -233,8 +233,8 @@ class Conversation < ApplicationRecord
def allowed_keys?
(
(previous_changes.keys & %w[team_id assignee_id status snoozed_until custom_attributes label_list first_reply_created_at priority]).present? ||
(previous_changes['additional_attributes'].present? && (previous_changes['additional_attributes'][1].keys & %w[conversation_language]).present?)
previous_changes.keys.intersect?(%w[team_id assignee_id status snoozed_until custom_attributes label_list first_reply_created_at priority]) ||
(previous_changes['additional_attributes'].present? && previous_changes['additional_attributes'][1].keys.intersect?(%w[conversation_language]))
)
end
+4
View File
@@ -35,4 +35,8 @@ class CsatSurveyResponse < ApplicationRecord
scope :filter_by_created_at, ->(range) { where(created_at: range) if range.present? }
scope :filter_by_assigned_agent_id, ->(user_ids) { where(assigned_agent_id: user_ids) if user_ids.present? }
scope :filter_by_inbox_id, ->(inbox_id) { joins(:conversation).where(conversations: { inbox_id: inbox_id }) if inbox_id.present? }
scope :filter_by_team_id, ->(team_id) { joins(:conversation).where(conversations: { team_id: team_id }) if team_id.present? }
# filter by rating value
scope :filter_by_rating, ->(rating) { where(rating: rating) if rating.present? }
end
+8
View File
@@ -17,8 +17,16 @@
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
MAX_FILTER_PER_USER = 50
belongs_to :user
belongs_to :account
enum filter_type: { conversation: 0, contact: 1, report: 2 }
validate :validate_number_of_filters
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end
end

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