Compare commits

...
Author SHA1 Message Date
iamsivin 8fb043de54 chore: spec fixes 2023-05-19 18:55:09 +05:30
iamsivin 6d07a7d92e fix: Problem when using Brazilian Portuguese 2023-05-19 15:51:41 +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
Sojan 7b8bcaba11 Bump version to 2.17.0 2023-05-16 12:33:16 +05:30
Pranav Raj SandGitHub 3f39b7ad1f fix: Avoid styles getting purged on prod build (#7086) 2023-05-15 19:05:07 -07:00
Pranav Raj SandGitHub a3547c5a1f feat: Show Table of Contents in the article sidebar (#7085) 2023-05-15 18:43:16 -07:00
Tejaswini ChileandGitHub 0f776a173c Fix: Mail ate compare with nil date (#7084)
fixes: https://chatwoot-p3.sentry.io/issues/4185144468/?project=6382945
2023-05-15 21:42:22 +05:30
Sojan JoseandGitHub d320605556 chore: Fix Activerecord querry cancelled when fetching email (#7083)
limiting the query to 2 days to ensure that it runs timebound.

fixes: https://linear.app/chatwoot/issue/CW-1747/activerecordquerycanceled-pgquerycanceled-error-canceling-statement
2023-05-15 21:23:17 +05:30
Sojan JoseandGitHub ca85d4e1c6 fix: External url validation in attachment (#7082)
fixes: https://linear.app/chatwoot/issue/CW-1746/activerecordrecordinvalid-validation-failed-external-url-is-too-long
2023-05-15 20:33:58 +05:30
Tejaswini ChileandGitHub b994706265 fix: Email date attribute not being set (#7081)
Fixes: https://linear.app/chatwoot/issue/CW-1738/typeerror-no-implicit-conversion-of-nil-into-string-typeerror
2023-05-15 20:19:03 +05:30
d9f2cd0a6e chore: Enable Hebrew language for widget (#7079)
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-15 16:56:04 +05:30
Sojan JoseandGitHub f2a34ad8aa Chore: Remove deprecation warning post rails upgrade (#7080)
Fixes: https://linear.app/chatwoot/issue/CW-1575/fix-deprecations-post-upgrade-to-rails-7
2023-05-15 16:32:27 +05:30
Tejaswini ChileandGitHub 65e91f6a6f fix: Duplicate instagram conversations for echo messages (#7078) 2023-05-15 16:25:15 +05:30
Nithin David ThomasandGitHub 7dd1562b40 fix: Welcome email copy changes and canned response API error handling [cw-1290] (#7054)
Fixes: https://linear.app/chatwoot/issue/CW-1290/minor-ui-issues
2023-05-15 16:17:55 +05:30
Shivam MishraandGitHub c70367bbd7 feat: add migration to fix image signed keys (#7076) 2023-05-15 15:13:26 +05:30
Nithin David ThomasandGitHub 5a8eff35b5 chore: Enable support for hebrew in dashboard [CW-1736] (#7077) 2023-05-15 14:03:29 +05:30
Muhsin KelothandGitHub 1936369b12 feat: Add analytics event for OpenAI (#7074)
* Add analytics event for OpenAI

* Add more variations
2023-05-15 11:57:56 +05:30
Tejaswini ChileandGitHub 354010a6e1 chore: fetch mails with multiple attachments (#7030) 2023-05-14 10:02:36 +05:30
Sojan JoseandGitHub 385eab6b96 chore: Add max length validation to text fields (#7073)
Introduces a default max length validation for all string and text fields to prevent processing large payloads.
2023-05-12 22:12:21 +05:30
198cd9b28d feat: Show next available day/hour and minutes on widget (#6902)
* feat: Show next available hour and minutes on widget

* chore: Adds spec

* chore: Show days

* chore: Code clean up

* chore: Review fixes

* chore: Minor fixes

* chore: Review suggestion fixes

* chore: Minor fixes

* Added timezone to widget payload

* chore: Adds time zone

* chore: Review fixes

* chore: Adds comments

* chore: Rounded up min with nearest multiple of 5

* chore: Review fixes

* chore: Review fixes

* chore: Review fixes

* chore: Review fixes

* chore: Fix specs

* chore: Review fixes

* chore: Fix specs

* chore: Review fixes

* chore: Moved day names to i18n

* chore: Review fixes

* chore: Fix specs

---------

Co-authored-by: Tejaswini Chile <tejaswini@chatwoot.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-05-12 19:25:51 +05:30
Nithin David ThomasandGitHub abdf00d2cf fixes: Editor adding slash char while creating block quotes [cw-1505] (#7069)
* fixes: Editor adding slash char while creating block quotes [cw-1505]

* Update with latest package
2023-05-12 16:39:18 +05:30
Muhsin KelothandGitHub 2c3160cfee feat: API to list all attachments for a conversation (#7059)
Fixes: https://linear.app/chatwoot/issue/CW-1678/api-to-list-all-attachments-for-a-conversation
2023-05-12 15:48:06 +05:30
Muhsin KelothandGitHub 708bddf4db feat: Refetch the latest messages on action cable reconnect in widget (#6996) 2023-05-12 14:05:22 +05:30
Pranav Raj SandGitHub 020dcc4dc7 fix: Avoid audio URLs getting cached at the frontend (#7062) 2023-05-11 12:14:56 -07:00
271263bcc2 feat: SLA CRUD APIs (EE) (#7027)
Fixes: https://linear.app/chatwoot/issue/CW-1613/sla-api

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-11 22:42:56 +05:30
Muhsin KelothandGitHub c97d6021e0 chore: Add migration to set the default empty string value for contact name (#7052)
Fixes: https://linear.app/chatwoot/issue/CW-1650/issue-with-the-contact-name
2023-05-11 17:35:19 +05:30
d99997d17d feat: Add ability to filter Conversations by underlying source_id (#6979)
This change adds the ability to include a `source_id` param when querying the `/api/v1/accounts/{account_id}/conversations/search` endpoint. It restricts to results to only conversations related to a contact_inbox with the provided parameter. My motivation for adding this feature was to allow an external API to communicate with a specific conversation with only an awareness of the conversation `source_id` from the client.


Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-11 17:32:29 +05:30
Sivin VargheseandGitHub 9c5d062efc fix: Fix greeting message label in settings (#7056) 2023-05-11 17:30:07 +05:30
Muhsin KelothandGitHub 87f758ee1f feat: Order conversations by priority (#7053) 2023-05-11 12:56:43 +05:30
Shivam MishraandGitHub d0a1ad746a feat: add index migration (#7050) 2023-05-10 17:55:14 +05:30
86384bf876 feat: Reply suggestion and summary generation using OpenAI (#7029)
Add `reply_suggestions` and `summary_generation` options for OpenAI integration

Fixes: https://linear.app/chatwoot/issue/CW-1595/frontend-for-generating-conversation-summary

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
2023-05-10 17:50:37 +05:30
Muhsin KelothandGitHub 55e5fa2205 chore: Add the specs for sort conversation (#7047) 2023-05-10 17:49:17 +05:30
Sojan 65bad90b69 Revert "fix: Welcome email copy changes and canned response API error handling [cw-1290] (#6905)"
This reverts commit 07aaa046c1.
2023-05-10 16:43:18 +05:30
07aaa046c1 fix: Welcome email copy changes and canned response API error handling [cw-1290] (#6905)
* fix: Welcome email copy changes and canned response API error handling

* Review fixes

* Uses mixin for alerts in canned page

* Typo fixes

* Copy changes

* Fixes broken tests

* Fixes review comments

* Fixes typo errors with mail template

* Removes unwanted case

* Fixes repetitive texts

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-05-10 15:55:48 +05:30
giquieuandGitHub 520bdabefe fix: Record audio in wav format for web/facebook (#7046) 2023-05-10 12:29:56 +05:30
Shivam MishraandGitHub 662967b5d3 feat(perf): add index to messages created at (#7044)
* feat: add index to messages created at

* feat: run migration
2023-05-10 11:34:49 +05:30
6137a45214 chore: Update link in the reply summary email (#7024)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-05-09 17:39:09 -07:00
363 changed files with 5024 additions and 1194 deletions
+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
@@ -784,6 +784,7 @@ GEM
PLATFORMS
arm64-darwin-20
arm64-darwin-22
arm64-darwin-21
ruby
x86_64-darwin-18
+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,7 +63,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
@conversation ||= Conversation.where(
"additional_attributes ->> 'type' = 'instagram_direct_message'"
).find_by(conversation_params) || build_conversation
end
def message_content
@@ -95,7 +97,8 @@ 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
contact_inbox_id: @contact_inbox.id,
additional_attributes: { type: 'instagram_direct_message' }
))
end
@@ -103,10 +106,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id,
additional_attributes: {
type: 'instagram_direct_message'
}
contact_id: contact.id
}
end
@@ -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
@@ -22,6 +22,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversations_count = result[:count]
end
def attachments
@attachments = @conversation.attachments
end
def show; end
def create
ActiveRecord::Base.transaction do
@conversation = ConversationBuilder.new(params: params, contact_inbox: @contact_inbox).perform
@@ -29,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
@@ -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
@@ -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
@@ -48,7 +48,8 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def message_finder_params
{
filter_internal_messages: true,
before: permitted_params[:before]
before: permitted_params[:before],
after: permitted_params[:after]
}
end
@@ -62,7 +63,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
end
def set_message
@@ -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: []
@@ -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
+19 -4
View File
@@ -5,7 +5,8 @@ class ConversationFinder
SORT_OPTIONS = {
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at'
last_user_message_at: 'last_user_message_at',
sort_on_priority: 'sort_on_priority'
}.with_indifferent_access
# assumptions
@@ -53,9 +54,10 @@ class ConversationFinder
find_all_conversations
filter_by_status unless params[:q]
filter_by_team if @team
filter_by_labels if params[:labels]
filter_by_query if params[:q]
filter_by_team
filter_by_labels
filter_by_query
filter_by_source_id
end
def set_inboxes
@@ -106,6 +108,8 @@ class ConversationFinder
end
def filter_by_query
return unless params[:q]
allowed_message_types = [Message.message_types[:incoming], Message.message_types[:outgoing]]
@conversations = conversations.joins(:messages).where('messages.content ILIKE :search', search: "%#{params[:q]}%")
.where(messages: { message_type: allowed_message_types }).includes(:messages)
@@ -120,13 +124,24 @@ class ConversationFinder
end
def filter_by_team
return unless @team
@conversations = @conversations.where(team: @team)
end
def filter_by_labels
return unless params[:labels]
@conversations = @conversations.tagged_with(params[:labels], any: true)
end
def filter_by_source_id
return unless params[:source_id]
@conversations = @conversations.joins(:contact_inbox)
@conversations = @conversations.where(contact_inboxes: { source_id: params[:source_id] })
end
def set_count_for_all_conversations
[
@conversations.assigned_to(current_user).count,
+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 } = {}) {
// 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 },
});
}
}
@@ -7,14 +7,22 @@ class OpenAIAPI extends ApiClient {
super('integrations', { accountScoped: true });
}
processEvent({ name = 'rephrase', content, tone, hookId }) {
processEvent({ type = 'rephrase', content, tone, conversationId, hookId }) {
let data = {
tone,
content,
};
if (type === 'reply_suggestion' || type === 'summarize') {
data = {
conversation_display_id: conversationId,
};
}
return axios.post(`${this.url}/hooks/${hookId}/process_event`, {
event: {
name: name,
data: {
tone,
content,
},
name: type,
data,
},
});
}
@@ -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 {
@@ -254,7 +254,7 @@ $button-sizes: (tiny: var(--font-size-micro),
default: var(--font-size-small),
large: var(--font-size-medium));
$button-palette: $foundation-palette;
$button-opacity-disabled: 0.25;
$button-opacity-disabled: 0.4;
$button-background-hover-lightness: -20%;
$button-hollow-hover-lightness: -50%;
$button-transition: background-color 0.25s ease-out,
@@ -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;
@@ -6,7 +6,11 @@
:disabled="isDisabled || isLoading"
@click="handleClick"
>
<spinner v-if="isLoading" size="small" />
<spinner
v-if="isLoading"
size="small"
:color-scheme="showDarkSpinner ? 'dark' : ''"
/>
<emoji-or-icon
v-else-if="icon || emoji"
class="icon"
@@ -108,6 +112,14 @@ export default {
return 16;
}
},
showDarkSpinner() {
return (
this.colorScheme === 'secondary' ||
this.variant === 'clear' ||
this.variant === 'link' ||
this.variant === 'hollow'
);
},
},
methods: {
handleClick(evt) {
@@ -1,45 +1,70 @@
<template>
<div v-if="isAIIntegrationEnabled" class="position-relative">
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleDropdown"
/>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open ai-modal"
>
<h4 class="sub-block-title margin-top-1">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
</h4>
<p>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
</p>
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
</label>
<div class="tone__item">
<select v-model="activeTone" class="status--filter small">
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
{{ tone.value }}
</option>
</select>
</div>
<div class="modal-footer flex-container align-right">
<woot-button variant="clear" size="small" @click="closeDropdown">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
</woot-button>
<woot-button
:is-loading="isGenerating"
size="small"
@click="processText"
>
{{ buttonText }}
</woot-button>
<div v-if="!message">
<woot-button
v-if="isPrivateNote"
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.SUMMARY_TITLE')"
icon="book-pulse"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.summarize"
@click="processEvent('summarize')"
/>
<woot-button
v-else
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.REPLY_TITLE')"
icon="wand"
color-scheme="secondary"
variant="smooth"
size="small"
:is-loading="uiFlags.reply_suggestion"
@click="processEvent('reply_suggestion')"
/>
</div>
<div v-else>
<woot-button
v-tooltip.top-end="$t('INTEGRATION_SETTINGS.OPEN_AI.TITLE')"
icon="text-grammar-wand"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleDropdown"
/>
<div
v-if="showDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open ai-modal"
>
<h4 class="sub-block-title margin-top-1">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TITLE') }}
</h4>
<p>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.SUBTITLE') }}
</p>
<label>
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.TONE.TITLE') }}
</label>
<div class="tone__item">
<select v-model="activeTone" class="status--filter small">
<option v-for="tone in tones" :key="tone.key" :value="tone.key">
{{ tone.value }}
</option>
</select>
</div>
<div class="modal-footer flex-container align-right">
<woot-button variant="clear" size="small" @click="closeDropdown">
{{ $t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.CANCEL') }}
</woot-button>
<woot-button
:is-loading="uiFlags.rephrase"
size="small"
@click="processEvent('rephrase')"
>
{{ buttonText }}
</woot-button>
</div>
</div>
</div>
</div>
@@ -49,6 +74,7 @@ import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import OpenAPI from 'dashboard/api/integrations/openapi';
import alertMixin from 'shared/mixins/alertMixin';
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
mixins: [alertMixin, clickaway],
@@ -61,10 +87,18 @@ export default {
type: String,
default: '',
},
isPrivateNote: {
type: Boolean,
default: false,
},
},
data() {
return {
isGenerating: false,
uiFlags: {
rephrase: false,
reply_suggestion: false,
summarize: false,
},
showDropdown: false,
activeTone: 'professional',
tones: [
@@ -94,7 +128,7 @@ export default {
).hooks[0].id;
},
buttonText() {
return this.isGenerating
return this.uiFlags.isRephrasing
? this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATING')
: this.$t('INTEGRATION_SETTINGS.OPEN_AI.BUTTONS.GENERATE');
},
@@ -111,24 +145,35 @@ export default {
closeDropdown() {
this.showDropdown = false;
},
async processText() {
this.isGenerating = true;
async recordAnalytics({ type, tone }) {
const event = OPEN_AI_EVENTS[type.toUpperCase()];
if (event) {
this.$track(event, {
type,
tone,
});
}
},
async processEvent(type = 'rephrase') {
this.uiFlags[type] = true;
try {
const result = await OpenAPI.processEvent({
hookId: this.hookId,
type: 'rephrase',
type,
content: this.message,
tone: this.activeTone,
conversationId: this.conversationId,
});
const {
data: { message: generatedMessage },
} = result;
this.$emit('replace-text', generatedMessage || this.message);
this.closeDropdown();
this.recordAnalytics({ type, tone: this.activeTone });
} catch (error) {
this.showAlert(this.$t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR'));
} finally {
this.isGenerating = false;
this.uiFlags[type] = false;
}
},
},
@@ -13,15 +13,19 @@ import videojs from 'video.js';
import alertMixin from '../../../../shared/mixins/alertMixin';
import Recorder from 'opus-recorder';
// Workers to record Audio .ogg and .wav
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
import waveWorker from 'opus-recorder/dist/waveWorker.min';
import WaveSurfer from 'wavesurfer.js';
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
import 'videojs-record/dist/videojs.record.js';
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import OpusRecorderEngine from 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
import { format, addSeconds } from 'date-fns';
import { AUDIO_FORMATS } from 'shared/constants/messages';
@@ -33,7 +37,7 @@ export default {
props: {
audioRecordFormat: {
type: String,
default: AUDIO_FORMATS.WEBM,
default: AUDIO_FORMATS.WAV,
},
},
data() {
@@ -79,24 +83,18 @@ export default {
maxLength: 900,
timeSlice: 1000,
maxFileSize: 15 * 1024 * 1024,
...(this.audioRecordFormat === AUDIO_FORMATS.WEBM && {
monitorGain: 0,
recordingGain: 1,
numberOfChannels: 1,
encoderSampleRate: 16000,
originalSampleRateOverride: 16000,
streamPages: true,
maxFramesPerPage: 1,
encoderFrameSize: 1,
encoderPath: waveWorker,
displayMilliseconds: false,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
audioEngine: 'opus-recorder',
...(this.audioRecordFormat === AUDIO_FORMATS.WAV && {
audioMimeType: 'audio/wav',
audioWorkerURL: waveWorker,
}),
...(this.audioRecordFormat === AUDIO_FORMATS.OGG && {
displayMilliseconds: false,
audioEngine: 'opus-recorder',
audioMimeType: 'audio/ogg',
audioWorkerURL: encoderWorker,
audioChannels: 1,
audioSampleRate: 48000,
audioBitRate: 128,
}),
},
},
@@ -134,6 +132,11 @@ export default {
},
methods: {
deviceReady() {
if (this.player.record().engine instanceof OpusRecorderEngine) {
if (this.audioRecordFormat === AUDIO_FORMATS.WAV) {
this.player.record().engine.audioType = 'audio/wav';
}
}
this.player.record().start();
},
startRecord() {
@@ -38,12 +38,12 @@
</file-upload>
<woot-button
v-if="showAudioRecorderButton"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
:icon="!isRecordingAudio ? 'microphone' : 'microphone-off'"
emoji="🎤"
:color-scheme="!isRecordingAudio ? 'secondary' : 'alert'"
variant="smooth"
size="small"
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
@@ -92,8 +92,8 @@
:conversation-id="conversationId"
/>
<AIAssistanceButton
v-if="message"
:conversation-id="conversationId"
:is-private-note="isOnPrivateNote"
:message="message"
@replace-text="replaceText"
/>
@@ -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;
@@ -59,7 +59,7 @@
controls
class="skip-context-menu"
>
<source :src="attachment.data_url" />
<source :src="`${attachment.data_url}?t=${Date.now()}`" />
</audio>
<bubble-video
v-else-if="attachment.file_type === 'video'"
@@ -501,10 +501,10 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWebWidgetInbox) {
return AUDIO_FORMATS.WEBM;
if (this.isAWhatsAppChannel) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.OGG;
return AUDIO_FORMATS.WAV;
},
messageVariables() {
const variables = getMessageVariables({
@@ -15,6 +15,7 @@ export default {
SORT_BY_TYPE: {
LATEST: 'latest',
CREATED_AT: 'sort_on_created_at',
PRIORITY: 'sort_on_priority',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -76,3 +76,9 @@ export const PORTALS_EVENTS = Object.freeze({
DELETE_ARTICLE: 'Deleted an article',
PREVIEW_ARTICLE: 'Previewed article',
});
export const OPEN_AI_EVENTS = Object.freeze({
SUMMARIZE: 'OpenAI: Used summarize',
REPLY_SUGGESTION: 'OpenAI: Used reply suggestion',
REPHRASE: 'OpenAI: Used rephrase',
});
+2
View File
@@ -9,6 +9,7 @@ import es from './locale/es';
import fa from './locale/fa';
import fi from './locale/fi';
import fr from './locale/fr';
import he from './locale/he';
import hi from './locale/hi';
import hu from './locale/hu';
import id from './locale/id';
@@ -47,6 +48,7 @@ export default {
fa,
fi,
fr,
he,
hi,
hu,
id,
@@ -34,7 +34,7 @@
},
"API": {
"SUCCESS_MESSAGE": "Canned Response added successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
"ERROR_MESSAGE": "Could not create canned response, Please try again later"
}
},
"EDIT": {
@@ -56,14 +56,14 @@
"BUTTON_TEXT": "Edit",
"API": {
"SUCCESS_MESSAGE": "Canned Response updated successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
"ERROR_MESSAGE": "Could not update canned response, Please try again later"
}
},
"DELETE": {
"BUTTON_TEXT": "Delete",
"API": {
"SUCCESS_MESSAGE": "Canned response deleted successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
"ERROR_MESSAGE": "Could not delete canned response, Please try again later"
},
"CONFIRM": {
"TITLE": "Confirm Deletion",
@@ -47,6 +47,9 @@
},
"sort_on_created_at": {
"TEXT": "Created at"
},
"sort_on_priority": {
"TEXT": "Priority"
}
},
"ATTACHMENTS": {
@@ -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"
}
}
}
@@ -83,7 +83,7 @@
},
"CHANNEL_GREETING_TOGGLE": {
"LABEL": "Enable channel greeting",
"HELP_TEXT": "Automatically send a greeting message when a new conversation is created.",
"HELP_TEXT": "Automatically send a greeting message after the contact's first message in a conversation.",
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
@@ -84,6 +84,8 @@
},
"OPEN_AI": {
"TITLE": "Improve With AI",
"SUMMARY_TITLE": "Summary with AI",
"REPLY_TITLE": "Reply suggestion with AI",
"SUBTITLE": "An improved reply will be generated using AI, based on your current draft.",
"TONE": {
"TITLE": "Tone",
@@ -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"
@@ -24,6 +24,7 @@ describe('#messageTimestamp', () => {
describe('#dynamicTime', () => {
it('returns correct value', () => {
Date.now = jest.fn(() => new Date(Date.UTC(2023, 1, 14)).valueOf());
expect(TimeMixin.methods.dynamicTime(1612971343)).toEqual(
'about 2 years ago'
);
@@ -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>
@@ -5,7 +5,7 @@
:header-title="$t('CANNED_MGMT.ADD.TITLE')"
:header-content="$t('CANNED_MGMT.ADD.DESC')"
/>
<form class="row" @submit.prevent="addAgent()">
<form class="row" @submit.prevent="addCannedResponse()">
<div class="medium-12 columns">
<label :class="{ error: $v.shortCode.$error }">
{{ $t('CANNED_MGMT.ADD.FORM.SHORT_CODE.LABEL') }}
@@ -107,7 +107,7 @@ export default {
this.$v.shortCode.$reset();
this.$v.content.$reset();
},
addAgent() {
addCannedResponse() {
// Show loading on button
this.addCanned.showLoading = true;
// Make API Calls
@@ -123,9 +123,11 @@ export default {
this.resetForm();
this.onClose();
})
.catch(() => {
.catch(error => {
this.addCanned.showLoading = false;
this.showAlert(this.$t('CANNED_MGMT.ADD.API.ERROR_MESSAGE'));
const errorMessage =
error?.message || this.$t('CANNED_MGMT.ADD.API.ERROR_MESSAGE');
this.showAlert(errorMessage);
});
},
},
@@ -57,6 +57,7 @@
import { required, minLength } from 'vuelidate/lib/validators';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import WootSubmitButton from '../../../../components/buttons/FormSubmitButton';
import alertMixin from 'shared/mixins/alertMixin';
import Modal from '../../../../components/Modal';
export default {
@@ -65,6 +66,7 @@ export default {
Modal,
WootMessageEditor,
},
mixins: [alertMixin],
props: {
id: { type: Number, default: null },
edcontent: { type: String, default: '' },
@@ -76,7 +78,6 @@ export default {
editCanned: {
showAlert: false,
showLoading: false,
message: '',
},
shortCode: this.edshortCode,
content: this.edcontent,
@@ -102,9 +103,6 @@ export default {
this.$v.content.$touch();
this.content = name;
},
showAlert() {
bus.$emit('newToastMessage', this.editCanned.message);
},
resetForm() {
this.shortCode = '';
this.content = '';
@@ -124,21 +122,17 @@ export default {
.then(() => {
// Reset Form, Show success message
this.editCanned.showLoading = false;
this.editCanned.message = this.$t(
'CANNED_MGMT.EDIT.API.SUCCESS_MESSAGE'
);
this.showAlert();
this.showAlert(this.$t('CANNED_MGMT.EDIT.API.SUCCESS_MESSAGE'));
this.resetForm();
setTimeout(() => {
this.onClose();
}, 10);
})
.catch(() => {
.catch(error => {
this.editCanned.showLoading = false;
this.editCanned.message = this.$t(
'CANNED_MGMT.EDIT.API.ERROR_MESSAGE'
);
this.showAlert();
const errorMessage =
error?.message || this.$t('CANNED_MGMT.EDIT.API.ERROR_MESSAGE');
this.showAlert(errorMessage);
});
},
},
@@ -198,8 +198,10 @@ export default {
.then(() => {
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
})
.catch(() => {
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE'));
.catch(error => {
const errorMessage =
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
this.showAlert(errorMessage);
});
},
},
@@ -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"
@@ -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,
};
},
@@ -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,
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,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-fit, 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,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' },
]);
});
});
@@ -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';
@@ -1,3 +1,4 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import CannedResponseAPI from '../../api/cannedResponse';
@@ -46,8 +47,10 @@ const actions = {
const response = await CannedResponseAPI.create(cannedObj);
commit(types.default.ADD_CANNED, response.data);
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
return throwErrorMessage(error);
}
},
@@ -60,8 +63,10 @@ const actions = {
const response = await CannedResponseAPI.update(id, updateObj);
commit(types.default.EDIT_CANNED, response.data);
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
@@ -71,8 +76,10 @@ const actions = {
await CannedResponseAPI.delete(id);
commit(types.default.DELETE_CANNED, id);
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
return id;
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
return throwErrorMessage(error);
}
},
};
@@ -1,4 +1,7 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import {
MESSAGE_TYPE,
CONVERSATION_PRIORITY_ORDER,
} from 'shared/constants/messages';
import { applyPageFilters } from './helpers';
export const getSelectedChatConversation = ({
@@ -13,6 +16,12 @@ const getters = {
const comparator = {
latest: (a, b) => b.last_activity_at - a.last_activity_at,
sort_on_created_at: (a, b) => a.created_at - b.created_at,
sort_on_priority: (a, b) => {
return (
CONVERSATION_PRIORITY_ORDER[a.priority] -
CONVERSATION_PRIORITY_ORDER[b.priority]
);
},
};
return allConversations.sort(comparator[chatSortFilter]);
@@ -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
@@ -404,6 +404,33 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([[types.CLEAR_CONVERSATION_FILTERS]]);
});
});
describe('#updateConversationLastActivity', () => {
it('sends correct action', async () => {
await actions.updateConversationLastActivity(
{ commit },
{ conversationId: 1, lastActivityAt: 12121212 }
);
expect(commit.mock.calls).toEqual([
[
'UPDATE_CONVERSATION_LAST_ACTIVITY',
{ conversationId: 1, lastActivityAt: 12121212 },
],
]);
});
});
describe('#setChatSortFilter', () => {
it('sends correct action', async () => {
await actions.setChatSortFilter(
{ commit },
{ data: 'sort_on_created_at' }
);
expect(commit.mock.calls).toEqual([
['CHANGE_CHAT_SORT_FILTER', { data: 'sort_on_created_at' }],
]);
});
});
});
describe('#deleteMessage', () => {
@@ -6,43 +6,187 @@ commonHelpers();
describe('#getters', () => {
describe('#getAllConversations', () => {
it('order conversations based on last message date', () => {
it('order conversations based on last activity', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
created_at: 1466424480,
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [
{
created_at: 2466424490,
},
],
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on created at', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'sort_on_created_at',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [
{
created_at: 2466424490,
},
],
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
created_at: 1466424480,
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
]);
});
it('order conversations based on default order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on priority', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'sort_on_priority',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
]);
});
@@ -39,6 +39,19 @@ describe('#mutations', () => {
describe('#ASSIGN_TEAM', () => {
it('clears current chat window', () => {
const state = { allConversations: [{ id: 1, meta: {} }] };
mutations[types.UPDATE_CONVERSATION_LAST_ACTIVITY](state, {
lastActivityAt: 1602256198,
conversationId: 1,
});
expect(state.allConversations).toEqual([
{ id: 1, meta: {}, last_activity_at: 1602256198 },
]);
});
});
describe('#UPDATE_CONVERSATION_LAST_ACTIVITY', () => {
it('update conversation last activity', () => {
const state = { allConversations: [{ id: 1, meta: {} }] };
mutations[types.ASSIGN_TEAM](state, {
team: { id: 1, name: 'Team 1' },
@@ -50,6 +63,16 @@ describe('#mutations', () => {
});
});
describe('#CHANGE_CHAT_SORT_FILTER', () => {
it('update conversation sort filter', () => {
const state = { chatSortFilter: 'latest' };
mutations[types.CHANGE_CHAT_SORT_FILTER](state, {
data: 'sort_on_created_at',
});
expect(state.chatSortFilter).toEqual({ data: 'sort_on_created_at' });
});
});
describe('#SET_CURRENT_CHAT_WINDOW', () => {
it('set current chat window', () => {
const state = { selectedChatId: 1 };
+2 -28
View File
@@ -1,35 +1,9 @@
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so that it will be compiled.
import Vue from 'vue';
import Rails from '@rails/ujs';
import Turbolinks from 'turbolinks';
import PublicArticleSearch from '../portal/components/PublicArticleSearch.vue';
import { navigateToLocalePage } from '../portal/portalHelpers';
import '../portal/application.scss';
import { InitializationHelpers } from '../portal/portalHelpers';
Rails.start();
Turbolinks.start();
const initPageSetUp = () => {
navigateToLocalePage();
const isSearchContainerAvailable = document.querySelector('#search-wrap');
if (isSearchContainerAvailable) {
new Vue({
components: { PublicArticleSearch },
template: '<PublicArticleSearch />',
}).$mount('#search-wrap');
}
};
document.addEventListener('DOMContentLoaded', () => {
initPageSetUp();
});
document.addEventListener('turbolinks:load', () => {
initPageSetUp();
});
document.addEventListener('turbolinks:load', InitializationHelpers.onLoad);
+22
View File
@@ -16,3 +16,25 @@ body {
-webkit-font-smoothing: antialiased;
height: 100%;
}
// Taking these utils from tailwind 3.x.x, need to remove once we upgrade
.scroll-mt-24 {
scroll-margin-top: 6rem;
}
.top-24 {
top: 6rem;
}
.heading {
.permalink {
visibility: hidden;
}
&:hover {
.permalink {
visibility: visible;
}
}
}
@@ -0,0 +1,70 @@
<template>
<div class="hidden lg:block flex-1 scroll-mt-24 pl-4">
<div v-if="rows.length > 0" class="sticky top-24 py-12 overflow-auto">
<nav class="max-w-2xl">
<h2
id="on-this-page-title"
class="text-slate-800 font-semibold tracking-wide border-b mb-3 leading-7"
>
{{ tocHeader }}
</h2>
<ol role="list" class="mt-4 space-y-3 text-base">
<li v-for="element in rows" :key="element.slug" class="leading-6">
<p :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="text-base text-slate-800 cursor-pointer"
>
{{ element.title }}
</a>
</p>
</li>
</ol>
</nav>
</div>
</div>
</template>
<script>
export default {
props: {
rows: {
type: Array,
default: () => [],
},
},
computed: {
tocHeader() {
return window.portalConfig.tocHeader;
},
h1Count() {
return this.rows.filter(el => el.tag === 'h1').length;
},
h2Count() {
return this.rows.filter(el => el.tag === 'h2').length;
},
},
methods: {
getClassName(el) {
if (el.tag === 'h1') {
return '';
}
if (el.tag === 'h2') {
if (this.h1Count > 0) {
return 'ml-2';
}
return '';
}
if (el.tag === 'h3') {
if (!this.h1Count && !this.h2Count) {
return '';
}
return 'ml-8';
}
return '';
},
},
};
</script>
+75 -9
View File
@@ -1,13 +1,79 @@
export const navigateToLocalePage = () => {
const allLocaleSwitcher = document.querySelector('.locale-switcher');
import slugifyWithCounter from '@sindresorhus/slugify';
import Vue from 'vue';
if (!allLocaleSwitcher) {
return false;
}
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
const { portalSlug } = allLocaleSwitcher.dataset;
allLocaleSwitcher.addEventListener('change', event => {
window.location = `/hc/${portalSlug}/${event.target.value}/`;
export const getHeadingsfromTheArticle = () => {
const rows = [];
const articleElement = document.getElementById('cw-article-content');
articleElement.querySelectorAll('h1, h2, h3').forEach(element => {
const slug = slugifyWithCounter(element.innerText);
element.id = slug;
element.className = 'scroll-mt-24 heading';
element.innerHTML += `<a class="permalink text-slate-600 ml-3" href="#${slug}" title="${element.innerText}" data-turbolinks="false">#</a>`;
rows.push({
slug,
title: element.innerText,
tag: element.tagName.toLowerCase(),
});
});
return false;
return rows;
};
export const InitializationHelpers = {
navigateToLocalePage: () => {
const allLocaleSwitcher = document.querySelector('.locale-switcher');
if (!allLocaleSwitcher) {
return false;
}
const { portalSlug } = allLocaleSwitcher.dataset;
allLocaleSwitcher.addEventListener('change', event => {
window.location = `/hc/${portalSlug}/${event.target.value}/`;
});
return false;
},
initalizeSearch: () => {
const isSearchContainerAvailable = document.querySelector('#search-wrap');
if (isSearchContainerAvailable) {
new Vue({
components: { PublicArticleSearch },
template: '<PublicArticleSearch />',
}).$mount('#search-wrap');
}
},
initializeTableOfContents: () => {
const isOnArticlePage = document.querySelector('#cw-hc-toc');
if (isOnArticlePage) {
new Vue({
components: { TableOfContents },
data: { rows: getHeadingsfromTheArticle() },
template: '<table-of-contents :rows="rows" />',
}).$mount('#cw-hc-toc');
}
},
initialize: () => {
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initalizeSearch();
InitializationHelpers.initializeTableOfContents();
},
onLoad: () => {
InitializationHelpers.initialize();
if (window.location.hash) {
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
const a = document.createElement('a');
a.href = window.location.hash;
a['data-turbolinks'] = false;
a.click();
}
},
};
+2 -2
View File
@@ -1,4 +1,4 @@
import { navigateToLocalePage } from '../portalHelpers';
import { InitializationHelpers } from '../portalHelpers';
describe('#navigateToLocalePage', () => {
it('returns correct cookie name', () => {
@@ -14,7 +14,7 @@ describe('#navigateToLocalePage', () => {
callback({ target: { value: 1 } });
});
navigateToLocalePage();
InitializationHelpers.navigateToLocalePage();
expect(allLocaleSwitcher.addEventListener).toBeCalledWith(
'change',
expect.any(Function)
@@ -32,6 +32,10 @@
"book-contacts-outline": "M15.5 12.25a.75.75 0 0 0-.75-.75h-5a.75.75 0 0 0-.75.75v.5c0 1 1.383 1.75 3.25 1.75s3.25-.75 3.25-1.75v-.5ZM14 8.745C14 7.78 13.217 7 12.25 7s-1.75.779-1.75 1.745a1.75 1.75 0 1 0 3.5 0ZM4 4.5A2.5 2.5 0 0 1 6.5 2H18a2.5 2.5 0 0 1 2.5 2.5v14.25a.75.75 0 0 1-.75.75H5.5a1 1 0 0 0 1 1h13.25a.75.75 0 0 1 0 1.5H6.5A2.5 2.5 0 0 1 4 19.5v-15Zm1.5 0V18H19V4.5a1 1 0 0 0-1-1H6.5a1 1 0 0 0-1 1Z",
"book-open-globe-outline": "M3.5 5.75a.25.25 0 0 1 .25-.25H10c.69 0 1.25.56 1.25 1.25v8.959a6.49 6.49 0 0 1 1.5-2.646V6.75c0-.69.56-1.25 1.25-1.25h6.25a.25.25 0 0 1 .25.25v5.982A6.518 6.518 0 0 1 22 12.81V5.75A1.75 1.75 0 0 0 20.25 4H14c-.788 0-1.499.331-2 .863A2.742 2.742 0 0 0 10 4H3.75A1.75 1.75 0 0 0 2 5.75v12.5c0 .966.784 1.75 1.75 1.75H10c.495 0 .96-.13 1.36-.36a6.473 6.473 0 0 1-.343-1.663A1.248 1.248 0 0 1 10 18.5H3.75a.25.25 0 0 1-.25-.25V5.75ZM16.007 17c.04-1.415.248-2.669.553-3.585.171-.513.364-.893.554-1.134.195-.247.329-.281.386-.281.057 0 .192.034.386.281.19.241.383.62.554 1.134.305.916.513 2.17.553 3.585h-2.986Zm-.396-3.9c.108-.323.23-.622.368-.887A5.504 5.504 0 0 0 12.023 17h2.984c.04-1.5.26-2.866.604-3.9Zm3.778 0a6.133 6.133 0 0 0-.368-.887A5.504 5.504 0 0 1 22.978 17h-2.985c-.04-1.5-.26-2.866-.604-3.9Zm.604 4.9h2.985a5.504 5.504 0 0 1-3.957 4.787c.138-.265.26-.564.368-.886.345-1.035.564-2.4.604-3.901Zm-2.107 4.719c-.194.247-.329.281-.386.281-.057 0-.191-.034-.386-.281-.19-.241-.383-.62-.554-1.135-.305-.915-.513-2.17-.553-3.584h2.986c-.04 1.415-.248 2.669-.553 3.584-.171.514-.364.894-.554 1.135ZM12.023 18a5.504 5.504 0 0 0 3.956 4.787 6.133 6.133 0 0 1-.367-.886c-.346-1.035-.565-2.4-.605-3.901h-2.984Z",
"book-outline": "M4 4.5A2.5 2.5 0 0 1 6.5 2H18a2.5 2.5 0 0 1 2.5 2.5v14.25a.75.75 0 0 1-.75.75H5.5a1 1 0 0 0 1 1h13.25a.75.75 0 0 1 0 1.5H6.5A2.5 2.5 0 0 1 4 19.5v-15ZM5.5 18H19V4.5a1 1 0 0 0-1-1H6.5a1 1 0 0 0-1 1V18Z",
"book-pulse-outline": [
"M11.69 7.458a.75.75 0 0 0-1.36-.043L8.785 10.5H7.75a.75.75 0 0 0 0 1.5h1.5a.75.75 0 0 0 .67-.415l1.023-2.044 2.116 5.001a.75.75 0 0 0 1.339.086L15.93 12h.819a.75.75 0 0 0 0-1.5H15.5a.75.75 0 0 0-.648.372l-.995 1.706-2.166-5.12Z",
"M6.5 2A2.5 2.5 0 0 0 4 4.5v15A2.5 2.5 0 0 0 6.5 22h13.25a.75.75 0 0 0 0-1.5H6.5a1 1 0 0 1-1-1h14.25a.75.75 0 0 0 .75-.75V4.5A2.5 2.5 0 0 0 18 2H6.5ZM19 18H5.5V4.5a1 1 0 0 1 1-1H18a1 1 0 0 1 1 1V18Z"
],
"bot-outline": "M17.753 14a2.25 2.25 0 0 1 2.25 2.25v.905a3.75 3.75 0 0 1-1.307 2.846C17.13 21.345 14.89 22 12 22c-2.89 0-5.128-.656-6.691-2a3.75 3.75 0 0 1-1.306-2.843v-.908A2.25 2.25 0 0 1 6.253 14h11.5Zm0 1.5h-11.5a.75.75 0 0 0-.75.75v.908c0 .655.286 1.278.784 1.706C7.545 19.945 9.44 20.502 12 20.502c2.56 0 4.458-.557 5.719-1.64a2.25 2.25 0 0 0 .784-1.706v-.906a.75.75 0 0 0-.75-.75ZM11.898 2.008 12 2a.75.75 0 0 1 .743.648l.007.102V3.5h3.5a2.25 2.25 0 0 1 2.25 2.25v4.505a2.25 2.25 0 0 1-2.25 2.25h-8.5a2.25 2.25 0 0 1-2.25-2.25V5.75A2.25 2.25 0 0 1 7.75 3.5h3.5v-.749a.75.75 0 0 1 .648-.743L12 2l-.102.007ZM16.25 5h-8.5a.75.75 0 0 0-.75.75v4.505c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75V5.75a.75.75 0 0 0-.75-.75Zm-6.5 1.5a1.25 1.25 0 1 1 0 2.5 1.25 1.25 0 0 1 0-2.5Zm4.492 0a1.25 1.25 0 1 1 0 2.499 1.25 1.25 0 0 1 0-2.499Z",
"brand-facebook-outline": "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z",
"brand-instagram-outline": "M 8 3 C 5.243 3 3 5.243 3 8 L 3 16 C 3 18.757 5.243 21 8 21 L 16 21 C 18.757 21 21 18.757 21 16 L 21 8 C 21 5.243 18.757 3 16 3 L 8 3 z M 8 5 L 16 5 C 17.654 5 19 6.346 19 8 L 19 16 C 19 17.654 17.654 19 16 19 L 8 19 C 6.346 19 5 17.654 5 16 L 5 8 C 5 6.346 6.346 5 8 5 z M 17 6 A 1 1 0 0 0 16 7 A 1 1 0 0 0 17 8 A 1 1 0 0 0 18 7 A 1 1 0 0 0 17 6 z M 12 7 C 9.243 7 7 9.243 7 12 C 7 14.757 9.243 17 12 17 C 14.757 17 17 14.757 17 12 C 17 9.243 14.757 7 12 7 z M 12 9 C 13.654 9 15 10.346 15 12 C 15 13.654 13.654 15 12 15 C 10.346 15 9 13.654 9 12 C 9 10.346 10.346 9 12 9 z",
@@ -175,6 +179,10 @@
"M8.78 1.22a.75.75 0 0 0-1.06 1.06l.72.72H6.25A3.25 3.25 0 0 0 3 6.25v2.19l-.72-.72a.75.75 0 0 0-1.06 1.06l2 2a.748.748 0 0 0 1.06 0l2-2a.747.747 0 0 0 0-1.06.75.75 0 0 0-1.06 0l-.72.72V6.25c0-.966.784-1.75 1.75-1.75h2.19l-.72.72a.75.75 0 0 0 .78 1.237.75.75 0 0 0 .28-.177l2-2a.75.75 0 0 0 0-1.06l-2-2Z",
"M11.832 3a1.755 1.755 0 0 1 0 1.5H14v4h-4V6.475l-.513.512a1.742 1.742 0 0 1-.987.495V8.5H7.482c-.052.361-.217.71-.495.987L6.475 10H8.5v4h-4v-2.168a1.755 1.755 0 0 1-1.5 0v5.918A3.25 3.25 0 0 0 6.25 21h11.5A3.25 3.25 0 0 0 21 17.75V6.25A3.25 3.25 0 0 0 17.75 3h-5.918ZM10 14v-4h4v4h-4Zm5.5 0v-4h4v4h-4ZM14 15.5v4h-4v-4h4Zm1.5 4v-4h4v2.25a1.75 1.75 0 0 1-1.75 1.75H15.5Zm0-11v-4h2.25c.966 0 1.75.784 1.75 1.75V8.5h-4Zm-11 7h4v4H6.25a1.75 1.75 0 0 1-1.75-1.75V15.5Z"
],
"text-grammar-wand-outline": [
"M2.75 17h7.775l-1.5 1.5H2.75a.75.75 0 0 1-.102-1.493L2.75 17ZM2.75 14.5h7.564a1.745 1.745 0 0 1-.241-1.5H2.75l-.102.007A.75.75 0 0 0 2.75 14.5ZM12.72 16.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06-1.06l4.5-4.5ZM2.75 9h10.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.493L2.75 9ZM2.75 5h18.5a.75.75 0 0 1 .102 1.493l-.102.007H2.75a.75.75 0 0 1-.102-1.493L2.75 5Z",
"M16.5 8a.75.75 0 0 1 .744.658l.14 1.13a3.25 3.25 0 0 0 2.828 2.829l1.13.139a.75.75 0 0 1 0 1.488l-1.13.14a3.25 3.25 0 0 0-2.829 2.828l-.139 1.13a.75.75 0 0 1-1.488 0l-.14-1.13a3.25 3.25 0 0 0-2.828-2.829l-1.13-.139a.75.75 0 0 1 0-1.488l1.13-.14a3.25 3.25 0 0 0 2.829-2.828l.139-1.13A.75.75 0 0 1 16.5 8Zm0 3.774a4.761 4.761 0 0 1-1.726 1.726 4.761 4.761 0 0 1 1.726 1.726 4.761 4.761 0 0 1 1.726-1.726 4.761 4.761 0 0 1-1.726-1.726Z"
],
"translate-outline": "M16.953 5.303a1 1 0 0 0-1.906-.606c-.124.389-.236.899-.324 1.344-.565.012-1.12 0-1.652-.038a1 1 0 1 0-.142 1.995c.46.032.934.048 1.416.047a25.649 25.649 0 0 0-.24 1.698c-1.263.716-2.142 1.684-2.636 2.7-.624 1.283-.7 2.857.239 3.883.675.736 1.704.758 2.499.588.322-.068.654-.176.988-.32a1 1 0 0 0 1.746-.93 13.17 13.17 0 0 0-.041-.115 8.404 8.404 0 0 0 2.735-4.06c.286.251.507.55.658.864.284.594.334 1.271.099 1.91-.234.633-.78 1.313-1.84 1.843a1 1 0 0 0 .895 1.789c1.44-.72 2.385-1.758 2.821-2.94a4.436 4.436 0 0 0-.17-3.464 4.752 4.752 0 0 0-2.104-2.165C19.998 9.22 20 9.11 20 9a1 1 0 0 0-1.974-.23 5.984 5.984 0 0 0-1.796.138c.047-.305.102-.626.166-.964a20.142 20.142 0 0 0 2.842-.473 1 1 0 0 0-.476-1.942c-.622.152-1.286.272-1.964.358.048-.208.1-.409.155-.584Zm-3.686 8.015c.166-.34.414-.697.758-1.037.02.348.053.67.098.973.083.56.207 1.048.341 1.477a3.41 3.41 0 0 1-.674.227c-.429.092-.588.019-.614.006l-.004-.001c-.162-.193-.329-.774.095-1.645Zm4.498-2.562a6.362 6.362 0 0 1-1.568 2.73 7.763 7.763 0 0 1-.095-.525 10.294 10.294 0 0 1-.088-1.904c.033-.013.067-.024.1-.036l1.651-.265Zm0 0-1.651.265c.602-.212 1.155-.29 1.651-.265ZM7.536 6.29a6.342 6.342 0 0 0-4.456.331 1 1 0 0 0 .848 1.811 4.342 4.342 0 0 1 3.049-.222c.364.107.568.248.69.37.12.123.203.27.257.454.067.225.087.446.09.69a8.195 8.195 0 0 0-.555-.117c-1.146-.199-2.733-.215-4.262.64-1.271.713-1.796 2.168-1.682 3.448.12 1.326.94 2.679 2.572 3.136 1.48.414 2.913-.045 3.877-.507l.08-.04a1 1 0 0 0 1.96-.281V10.5c0-.053.002-.12.005-.2.012-.417.034-1.16-.168-1.838a3.043 3.043 0 0 0-.755-1.29c-.394-.398-.91-.694-1.547-.881h-.003Zm-.419 5.288c.344.06.647.143.887.222v2.197a7.021 7.021 0 0 1-.905.524c-.792.38-1.682.605-2.473.384-.698-.195-1.06-.742-1.119-1.389-.062-.693.243-1.286.667-1.523.987-.553 2.06-.569 2.943-.415Z",
"eye-show-outline": "M12 9.005a4 4 0 1 1 0 8 4 4 0 0 1 0-8Zm0 1.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM12 5.5c4.613 0 8.596 3.15 9.701 7.564a.75.75 0 1 1-1.455.365 8.503 8.503 0 0 0-16.493.004.75.75 0 0 1-1.455-.363A10.003 10.003 0 0 1 12 5.5Z",
"eye-hide-outline": "M2.22 2.22a.75.75 0 0 0-.073.976l.073.084 4.034 4.035a9.986 9.986 0 0 0-3.955 5.75.75.75 0 0 0 1.455.364 8.49 8.49 0 0 1 3.58-5.034l1.81 1.81A4 4 0 0 0 14.8 15.86l5.919 5.92a.75.75 0 0 0 1.133-.977l-.073-.084-6.113-6.114.001-.002-1.2-1.198-2.87-2.87h.002L8.719 7.658l.001-.002-1.133-1.13L3.28 2.22a.75.75 0 0 0-1.06 0Zm7.984 9.045 3.535 3.536a2.5 2.5 0 0 1-3.535-3.535ZM12 5.5c-1 0-1.97.148-2.889.425l1.237 1.236a8.503 8.503 0 0 1 9.899 6.272.75.75 0 0 0 1.455-.363A10.003 10.003 0 0 0 12 5.5Zm.195 3.51 3.801 3.8a4.003 4.003 0 0 0-3.801-3.8Z",
+12 -3
View File
@@ -1,5 +1,5 @@
<template>
<span class="spinner" :class="size" />
<span class="spinner" :class="`${size} ${colorScheme}`" />
</template>
<script>
export default {
@@ -8,6 +8,10 @@ export default {
type: String,
default: 'small',
},
colorScheme: {
type: String,
default: '',
},
},
};
</script>
@@ -32,8 +36,8 @@ export default {
margin-top: -$space-one;
margin-left: -$space-one;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.7);
border-top-color: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.8);
border-top-color: rgba(255, 255, 255, 0.3);
animation: spinner 0.9s linear infinite;
}
}
@@ -84,5 +88,10 @@ export default {
margin-top: -$space-small + $space-micro;
}
}
&.dark::before {
border-color: rgba(0, 0, 0, 0.7);
border-top-color: rgba(0, 0, 0, 0.2);
}
}
</style>
@@ -2,6 +2,6 @@
exports[`Spinner matches snapshot 1`] = `
<span
class="spinner small"
class="spinner small "
/>
`;
@@ -27,6 +27,14 @@ export const CONVERSATION_PRIORITY = {
MEDIUM: 'medium',
};
export const CONVERSATION_PRIORITY_ORDER = {
urgent: 1,
high: 2,
medium: 3,
low: 4,
null: 5,
};
// Size in mega bytes
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
export const MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL = 5;
@@ -51,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',
@@ -76,6 +88,7 @@ export const CSAT_RATINGS = [
{
key: 'smiling',
emoji: '😍',
translationKey: 'CSAT.RATINGS.EXCELLENT',
value: 5,
color: '#44CE4B',
},
@@ -84,6 +97,7 @@ export const CSAT_RATINGS = [
export const AUDIO_FORMATS = {
WEBM: 'audio/webm',
OGG: 'audio/ogg',
WAV: 'audio/wav',
};
export const MESSAGE_VARIABLES = [
@@ -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);
@@ -30,3 +31,17 @@ 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 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);
};
@@ -3,6 +3,7 @@ import {
formatUnixDate,
formatDigitToString,
isTimeAfter,
generateRelativeTime,
} from '../DateHelper';
describe('#DateHelper', () => {
@@ -62,3 +63,60 @@ describe('#isTimeAfter', () => {
expect(isTimeAfter(11, 59, 12, 0)).toEqual(false);
});
});
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);
});
});
+2
View File
@@ -9,6 +9,7 @@ import es from './locale/es.json';
import fa from './locale/fa.json';
import fi from './locale/fi.json';
import fr from './locale/fr.json';
import he from './locale/he.json';
import hi from './locale/hi.json';
import hu from './locale/hu.json';
import id from './locale/id.json';
@@ -47,6 +48,7 @@ export default {
fa,
fi,
fr,
he,
hi,
hu,
id,
+2 -2
View File
@@ -16,8 +16,8 @@ const sendAttachmentAPI = async attachment => {
return API.post(urlData.url, urlData.params);
};
const getMessagesAPI = async ({ before }) => {
const urlData = endPoints.getConversation({ before });
const getMessagesAPI = async ({ before, after }) => {
const urlData = endPoints.getConversation({ before, after });
return API.get(urlData.url, { params: urlData.params });
};
+2 -2
View File
@@ -57,9 +57,9 @@ const sendAttachment = ({ attachment }) => {
};
};
const getConversation = ({ before }) => ({
const getConversation = ({ before, after }) => ({
url: `/api/v1/widget/messages${window.location.search}`,
params: { before },
params: { before, after },
});
const updateMessage = id => ({
@@ -79,3 +79,26 @@ describe('#triggerCampaign', () => {
spy.mockRestore();
});
});
describe('#getConversation', () => {
it('should returns correct payload', () => {
const spy = jest.spyOn(global, 'Date').mockImplementation(() => ({
toString: () => 'mock date',
}));
const windowSpy = jest.spyOn(window, 'window', 'get');
expect(
endPoints.getConversation({
after: 123,
})
).toEqual({
url: `/api/v1/widget/messages`,
params: {
after: 123,
before: undefined,
},
});
windowSpy.mockRestore();
spy.mockRestore();
});
});
@@ -46,6 +46,7 @@
import { mapGetters } from 'vuex';
import availabilityMixin from 'widget/mixins/availability';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions';
import routerMixin from 'widget/mixins/routerMixin';
@@ -57,7 +58,7 @@ export default {
FluentIcon,
HeaderActions,
},
mixins: [availabilityMixin, routerMixin, darkMixin],
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin, darkMixin],
props: {
avatarUrl: {
type: String,
@@ -93,11 +94,6 @@ export default {
}
return anyAgentOnline;
},
replyWaitMessage() {
return this.isOnline
? this.replyTimeStatus
: this.$t('TEAM_AVAILABILITY.OFFLINE');
},
},
methods: {
onBackButtonClick() {
@@ -35,6 +35,7 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import AvailableAgents from 'widget/components/AvailableAgents.vue';
import CustomButton from 'shared/components/Button';
import configMixin from 'widget/mixins/configMixin';
@@ -47,7 +48,7 @@ export default {
AvailableAgents,
CustomButton,
},
mixins: [configMixin, availabilityMixin, darkMixin],
mixins: [configMixin, nextAvailabilityTime, availabilityMixin, darkMixin],
props: {
availableAgents: {
type: Array,
@@ -75,17 +76,6 @@ export default {
}
return anyAgentOnline;
},
replyWaitMessage() {
const { workingHoursEnabled } = this.channelConfig;
if (this.isOnline) {
return this.replyTimeStatus;
}
if (workingHoursEnabled) {
return this.outOfOfficeMessage;
}
return '';
},
},
methods: {
startConversation() {
@@ -25,6 +25,22 @@ class ActionCableConnector extends BaseActionCableConnector {
};
}
onDisconnected = () => {
this.setLastMessageId();
};
onReconnect = () => {
this.syncLatestMessages();
};
setLastMessageId = () => {
this.app.$store.dispatch('conversation/setLastMessageId');
};
syncLatestMessages = () => {
this.app.$store.dispatch('conversation/syncLatestMessages');
};
onStatusChange = data => {
if (data.status === 'resolved') {
this.app.$store.dispatch('campaign/resetCampaign');
+2
View File
@@ -9,6 +9,7 @@ import es from './locale/es.json';
import fa from './locale/fa.json';
import fi from './locale/fi.json';
import fr from './locale/fr.json';
import he from './locale/he.json';
import hi from './locale/hi.json';
import hu from './locale/hu.json';
import id from './locale/id.json';
@@ -47,6 +48,7 @@ export default {
fa,
fi,
fr,
he,
hi,
hu,
id,
+11 -1
View File
@@ -19,8 +19,18 @@
"REPLY_TIME": {
"IN_A_FEW_MINUTES": "Typically replies in a few minutes",
"IN_A_FEW_HOURS": "Typically replies in a few hours",
"IN_A_DAY": "Typically replies in a day"
"IN_A_DAY": "Typically replies in a day",
"BACK_IN": "We will be back online"
},
"DAY_NAMES": [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday"
],
"START_CONVERSATION": "Start Conversation",
"END_CONVERSATION": "End Conversation",
"CONTINUE_CONVERSATION": "Continue conversation",
@@ -21,6 +21,17 @@ export default {
return this.$t('REPLY_TIME.IN_A_FEW_HOURS');
}
},
replyWaitMessage() {
const { workingHoursEnabled } = this.channelConfig;
if (workingHoursEnabled) {
return this.isOnline
? this.replyTimeStatus
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
}
return this.isOnline
? this.replyTimeStatus
: this.$t('TEAM_AVAILABILITY.OFFLINE');
},
outOfOfficeMessage() {
return this.channelConfig.outOfOfficeMessage;
},
@@ -0,0 +1,242 @@
import {
timeSlotParse,
defaultTimeSlot,
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { utcToZonedTime } from 'date-fns-tz';
import { generateRelativeTime } from 'shared/helpers/DateHelper';
const MINUTE_ROUNDING_FACTOR = 5;
export default {
data() {
return {
dayNames: this.$t('DAY_NAMES'),
timeSlots: [...defaultTimeSlot],
timeSlot: {},
};
},
computed: {
channelConfig() {
return window.chatwootWebChannel;
},
workingHours() {
return this.channelConfig.workingHours;
},
newDateWithTimeZone() {
return utcToZonedTime(new Date(), this.timeZoneValue);
},
presentHour() {
return this.newDateWithTimeZone.getHours();
},
presentMinute() {
return this.newDateWithTimeZone.getMinutes();
},
currentDay() {
const date = this.newDateWithTimeZone;
const day = date.getDay();
const currentDay = Object.keys(this.dayNames).find(
key => this.dayNames[key] === this.dayNames[day]
);
return Number(currentDay);
},
timeZoneValue() {
return this.channelConfig.timezone;
},
languageCode() {
return window.chatwootWebChannel.locale;
},
currentDayWorkingHours() {
return this.workingHours.find(
slot => slot.day_of_week === this.currentDay
);
},
nextDayWorkingHours() {
let nextDay = this.getNextDay(this.currentDay);
let nextWorkingHour = this.getNextWorkingHour(nextDay);
// It gets the next working hour for the next day. If there is no working hour for the next day,
// it keeps iterating through the days of the week until it finds the next working hour.
while (!nextWorkingHour) {
nextDay = this.getNextDay(nextDay);
nextWorkingHour = this.getNextWorkingHour(nextDay);
}
return nextWorkingHour;
},
currentDayTimings() {
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = this.currentDayWorkingHours ?? {};
return {
openHour,
openMinute,
closeHour,
};
},
nextDayTimings() {
const { open_hour: openHour, open_minutes: openMinute } =
this.nextDayWorkingHours ?? {};
return {
openHour,
openMinute,
};
},
dayDiff() {
// Here this is used to get the difference between current day and next working day
const nextDay = this.nextDayWorkingHours.day_of_week;
const totalDays = 6;
return nextDay > this.currentDay
? nextDay - this.currentDay - 1
: totalDays - this.currentDay + nextDay;
},
dayNameOfNextWorkingDay() {
return this.dayNames[this.nextDayWorkingHours.day_of_week];
},
hoursAndMinutesBackInOnline() {
if (this.presentHour >= this.currentDayTimings.closeHour) {
return this.getHoursAndMinutesUntilNextDayOpen(
this.nextDayWorkingHours.open_all_day
? 0
: this.nextDayTimings.openHour,
this.nextDayTimings.openMinute,
this.currentDayTimings.closeHour
);
}
return this.getHoursAndMinutesUntilNextDayOpen(
this.currentDayTimings.openHour,
this.currentDayTimings.openMinute,
this.currentDayTimings.closeHour
);
},
exactTimeInAmPm() {
return `${
this.timeSlot.day === this.currentDay ? `at ${this.timeSlot.from}` : ''
}`;
},
hoursAndMinutesLeft() {
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
const timeLeftChars = [];
if (hoursLeft > 0) {
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
const hourRelative = generateRelativeTime(
roundedUpHoursLeft,
'hour',
this.languageCode
);
timeLeftChars.push(`${hourRelative}`);
}
if (minutesLeft > 0 && hoursLeft === 0) {
const roundedUpMinLeft =
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
MINUTE_ROUNDING_FACTOR;
const minRelative = generateRelativeTime(
roundedUpMinLeft,
'minutes',
this.languageCode
);
timeLeftChars.push(`${minRelative}`);
}
return timeLeftChars.join(' ');
},
hoursAndMinutesToBack() {
const { hoursLeft, minutesLeft } = this.hoursAndMinutesBackInOnline;
if (hoursLeft >= 3) {
return this.exactTimeInAmPm;
}
if (hoursLeft > 0 || minutesLeft > 0) {
return this.hoursAndMinutesLeft;
}
return 'in some time';
},
timeLeftToBackInOnline() {
if (
this.hoursAndMinutesBackInOnline.hoursLeft >= 24 ||
(this.timeSlot.day !== this.currentDay && this.dayDiff === 0)
) {
const hourRelative = generateRelativeTime(
this.dayDiff + 1,
'days',
this.languageCode
);
return `${hourRelative}`;
}
if (
this.dayDiff >= 1 &&
this.presentHour >= this.currentDayTimings.closeHour
) {
return `on ${this.dayNameOfNextWorkingDay}`;
}
return this.hoursAndMinutesToBack;
},
},
mounted() {
this.setTimeSlot();
},
methods: {
getNextDay(day) {
// This code calculates the next day of the week based on the current day. If the current day is Saturday (6), then the next day will be Sunday (0).
return (day + 1) % 7;
},
getNextWorkingHour(day) {
const workingHour = this.workingHours.find(
slot => slot.day_of_week === day
);
if (workingHour && !workingHour.closed_all_day) {
return workingHour;
}
return null;
},
getHoursAndMinutesUntilNextDayOpen(
openHour, // If the present time is after the closing time of the current day, then the openHour will be the opening hour of the next day else it will be the opening hour of the current day.
openMinutes, // If the present time is after the closing time of the current day, then the openMinutes will be the opening minutes of the next day else it will be the opening minutes of the current day.
closeHour // The closeHour will be the closing hour of the current day. It will be used to calculate the time remaining until the next day's opening hours.
) {
// This code calculates the time remaining until the next day's opening hours,
// given the current time, the opening hours, and the closing hours of the current day.
if (closeHour < openHour) {
openHour += 24;
}
let diffMinutes =
openHour * 60 +
openMinutes -
(this.presentHour * 60 + this.presentMinute);
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
const [hoursLeft, minutesLeft] = [
Math.floor(diffMinutes / 60),
diffMinutes % 60,
];
// It returns the remaining time in hours and minutes as an object with keys hours and minutes.
return { hoursLeft, minutesLeft };
},
setTimeSlot() {
// It checks if the working hours feature is enabled for the store.
const timeSlots = this.workingHours;
// If the present hour is after the closing hour of the current day,
// then the next day's working hours will be used to calculate the time remaining until the next day's opening hours,
// else the current day's working hours will be used
const currentSlot =
this.presentHour >= this.currentDayTimings.closeHour
? this.nextDayWorkingHours
: this.currentDayWorkingHours;
// It parses the working hours to get the time slots in AM/PM format.
const slots = timeSlotParse(timeSlots).length
? timeSlotParse(timeSlots)
: defaultTimeSlot;
this.timeSlots = slots;
// It finds the time slot for the current slot.
this.timeSlot = this.timeSlots.find(
slot => slot.day === currentSlot.day_of_week
);
},
},
};
@@ -0,0 +1,544 @@
import { createWrapper } from '@vue/test-utils';
import nextAvailabilityTimeMixin from '../nextAvailabilityTime';
import Vue from 'vue';
describe('nextAvailabilityTimeMixin', () => {
const chatwootWebChannel = {
workingHoursEnabled: true,
workingHours: [
{
day_of_week: 0,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 1,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 2,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 3,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 4,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 5,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
{
day_of_week: 6,
open_hour: 9,
closed_all_day: false,
open_minutes: 0,
close_hour: 17,
},
],
};
beforeAll(() => {
window.chatwootWebChannel = chatwootWebChannel;
});
afterAll(() => {
delete window.chatwootWebChannel;
});
beforeEach(() => {
jest.useRealTimers();
});
it('should return day names', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.dayNames).toEqual([
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
]);
});
it('should return channelConfig', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.channelConfig).toEqual(chatwootWebChannel);
});
it('should return workingHours', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.workingHours).toEqual(chatwootWebChannel.workingHours);
});
it('should return currentDayWorkingHours', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const currentDay = new Date().getDay();
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
slot => slot.day_of_week === currentDay
);
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.currentDayWorkingHours).toEqual(expectedWorkingHours);
});
it('should return nextDayWorkingHours', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const currentDay = new Date().getDay();
const nextDay = currentDay === 6 ? 0 : currentDay + 1;
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
slot => slot.day_of_week === nextDay
);
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.nextDayWorkingHours).toEqual(expectedWorkingHours);
});
it('should return presentHour', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.presentHour).toBe(new Date().getHours());
});
it('should return presentMinute', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
expect(wrapper.vm.presentMinute).toBe(new Date().getMinutes());
});
it('should return currentDay', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const date = new Date();
const day = date.getDay();
const currentDay = Object.keys(wrapper.vm.dayNames).find(
key => wrapper.vm.dayNames[key] === wrapper.vm.dayNames[day]
);
expect(wrapper.vm.currentDay).toBe(Number(currentDay));
});
it('should return currentDayTimings', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = wrapper.vm.currentDayWorkingHours;
expect(wrapper.vm.currentDayTimings).toEqual({
openHour,
openMinute,
closeHour,
});
});
it('should return nextDayTimings', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const {
open_hour: openHour,
open_minutes: openMinute,
} = wrapper.vm.nextDayWorkingHours;
expect(wrapper.vm.nextDayTimings).toEqual({
openHour,
openMinute,
});
});
it('should return dayDiff', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const currentDay = wrapper.vm.currentDay;
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
const totalDays = 6;
const expectedDayDiff =
nextDay > currentDay
? nextDay - currentDay - 1
: totalDays - currentDay + nextDay;
expect(wrapper.vm.dayDiff).toEqual(expectedDayDiff);
});
it('should return dayNameOfNextWorkingDay', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const nextDay = wrapper.vm.nextDayWorkingHours.day_of_week;
const expectedDayName = wrapper.vm.dayNames[nextDay];
expect(wrapper.vm.dayNameOfNextWorkingDay).toEqual(expectedDayName);
});
it('should return hoursAndMinutesBackInOnline', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
const currentDayCloseHour =
chatwootWebChannel.workingHours[wrapper.vm.currentDay].close_hour;
const nextDayOpenHour =
chatwootWebChannel.workingHours[
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
].open_hour;
const nextDayOpenMinute =
chatwootWebChannel.workingHours[
wrapper.vm.currentDay === 6 ? 0 : wrapper.vm.currentDay + 1
].open_minutes;
const expectedHoursAndMinutes = wrapper.vm.getHoursAndMinutesUntilNextDayOpen(
nextDayOpenHour,
nextDayOpenMinute,
currentDayCloseHour
);
expect(wrapper.vm.hoursAndMinutesBackInOnline).toEqual(
expectedHoursAndMinutes
);
});
it('should return getNextDay', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getNextDay(6)).toBe(0);
});
it('should return in 30 minutes', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
jest
.useFakeTimers('modern')
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.timeSlot = {
day: 4,
from: '12:00 AM',
openAllDay: false,
to: '08:00 AM',
valid: true,
};
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 18;
chatwootWebChannel.workingHours[4].open_minutes = 0;
chatwootWebChannel.workingHours[4].close_hour = 23;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 30 minutes');
});
it('should return in 3 hours', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
jest
.useFakeTimers('modern')
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.timeSlot = {
day: 4,
from: '12:00 PM',
openAllDay: false,
to: '11:30 PM',
valid: true,
};
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 19;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('in 2 hours');
});
it('should return at 10:00 AM', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
jest
.useFakeTimers('modern')
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.timeSlot = {
day: 4,
from: '10:00 AM',
openAllDay: false,
to: '11:00 AM',
valid: true,
};
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 10;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('at 10:00 AM');
});
it('should return tomorrow', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
jest
.useFakeTimers('modern')
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.timeSlot = {
day: 0,
from: '12:00 AM',
openAllDay: false,
to: '08:00 AM',
valid: true,
};
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 9;
chatwootWebChannel.workingHours[4].close_hour = 16;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('tomorrow');
});
it('should return on Saturday', () => {
const Component = {
render() {},
mixins: [nextAvailabilityTimeMixin],
};
jest
.useFakeTimers('modern')
.setSystemTime(new Date('Thu Apr 14 2022 23:04:46 GMT+0530'));
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
wrapper.vm.timeSlot = {
day: 0,
from: '12:00 AM',
openAllDay: false,
to: '08:00 AM',
valid: true,
};
wrapper.vm.dayNames = [
'Sunday',
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
];
chatwootWebChannel.workingHours[4].open_hour = 9;
chatwootWebChannel.workingHours[4].close_hour = 16;
chatwootWebChannel.workingHours[5].closed_all_day = true;
expect(wrapper.vm.timeLeftToBackInOnline).toBe('on Saturday');
});
});

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