Merge branch 'release/2.4.0'

This commit is contained in:
Sojan 2022-04-18 23:25:37 +05:30
commit 480eb3043c
358 changed files with 15327 additions and 1703 deletions

View file

@ -12,8 +12,8 @@ defaults: &defaults
# Specify service dependencies here if necessary # Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images # CircleCI maintains a library of pre-built images
# documented at https://circleci.com/docs/2.0/circleci-images/ # documented at https://circleci.com/docs/2.0/circleci-images/
- image: circleci/postgres:alpine - image: cimg/postgres:14.1
- image: circleci/redis:alpine - image: cimg/redis:6.2.6
environment: environment:
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
- RAILS_LOG_TO_STDOUT: false - RAILS_LOG_TO_STDOUT: false
@ -110,7 +110,7 @@ jobs:
- run: - run:
name: Run backend tests name: Run backend tests
command: | command: |
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation
~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json ~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
- persist_to_workspace: - persist_to_workspace:
root: ~/tmp root: ~/tmp

View file

@ -32,6 +32,11 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command # You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME= REDIS_SENTINEL_MASTER_NAME=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420
# REDIS_OPENSSL_VERIFY_MODE=none
# Postgres Database config variables # Postgres Database config variables
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres POSTGRES_USERNAME=postgres

View file

@ -0,0 +1,62 @@
# #
# # This action will publish Chatwoot CE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ env.DOCKER_TAG }}

72
.github/workflows/run_foss_spec.yml vendored Normal file
View file

@ -0,0 +1,72 @@
# #
# # This action will strip the enterprise folder
# # and run the spec.
# # This is set to run against every PR.
# #
name: Run Chatwoot CE spec
on:
push:
branches:
- develop
- master
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:10.8
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ""
POSTGRES_DB: postgres
ports:
- 5432:5432
# needed because the postgres container does not provide a healthcheck
# tmpfs makes DB faster by using RAM
options: >-
--mount type=tmpfs,destination=/var/lib/postgresql/data
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports:
- 6379:6379
options: --entrypoint redis-server
steps:
- uses: actions/checkout@v3
with:
ref: ${{ github.head_ref }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: 3.0.2 # Not needed with a .ruby-version file
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- name: yarn
run: yarn install
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Create database
run: bundle exec rake db:create
- name: Seed database
run: bundle exec rake db:schema:load
- name: yarn check-files
run: yarn install --check-files
# Run rails tests
- name: Run backend tests
run: |
bundle exec rspec --profile=10 --format documentation

View file

@ -17,6 +17,7 @@ Metrics/ClassLength:
- 'app/builders/messages/facebook/message_builder.rb' - 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb' - 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/listeners/action_cable_listener.rb' - 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength: RSpec/ExampleLength:
Max: 25 Max: 25
Style/Documentation: Style/Documentation:

View file

@ -125,6 +125,9 @@ gem 'procore-sift'
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
gem 'html2text' gem 'html2text'
# to calculate working hours
gem 'working_hours'
group :production, :staging do group :production, :staging do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'

View file

@ -378,14 +378,14 @@ GEM
netrc (0.11.0) netrc (0.11.0)
newrelic_rpm (8.4.0) newrelic_rpm (8.4.0)
nio4r (2.5.8) nio4r (2.5.8)
nokogiri (1.13.3) nokogiri (1.13.4)
mini_portile2 (~> 2.8.0) mini_portile2 (~> 2.8.0)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-arm64-darwin) nokogiri (1.13.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-x86_64-darwin) nokogiri (1.13.4-x86_64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.13.3-x86_64-linux) nokogiri (1.13.4-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.8) oauth (0.5.8)
orm_adapter (0.5.0) orm_adapter (0.5.0)
@ -403,7 +403,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (5.6.2) puma (5.6.4)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.2.0) pundit (2.2.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -636,6 +636,9 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4) zeitwerk (2.5.4)
PLATFORMS PLATFORMS
@ -746,6 +749,7 @@ DEPENDENCIES
webpacker (~> 5.x) webpacker (~> 5.x)
webpush webpush
wisper (= 2.0.0) wisper (= 2.0.0)
working_hours
RUBY VERSION RUBY VERSION
ruby 3.0.2p107 ruby 3.0.2p107

View file

@ -32,6 +32,10 @@
"INSTALLATION_ENV": { "INSTALLATION_ENV": {
"description": "Installation method used for Chatwoot.", "description": "Installation method used for Chatwoot.",
"value": "heroku" "value": "heroku"
},
"REDIS_OPENSSL_VERIFY_MODE":{
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
"value": "none"
} }
}, },
"formation": { "formation": {

View file

@ -70,7 +70,7 @@ class ContactBuilder
update_contact_avatar(contact) update_contact_avatar(contact)
contact_inbox contact_inbox
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
raise e raise e
end end
end end

View file

@ -27,7 +27,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end end
ensure_contact_avatar ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError rescue Koala::Facebook::AuthenticationError
Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}" Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
rescue StandardError => e rescue StandardError => e
Sentry.capture_exception(e) Sentry.capture_exception(e)
true true

View file

@ -1,5 +1,6 @@
class V2::ReportBuilder class V2::ReportBuilder
include DateRangeHelper include DateRangeHelper
include ReportHelper
attr_reader :account, :params attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze DEFAULT_GROUP_BY = 'day'.freeze
@ -18,8 +19,14 @@ class V2::ReportBuilder
# For backward compatible with old report # For backward compatible with old report
def build def build
timeseries.each_with_object([]) do |p, arr| if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
arr << { value: p[1], timestamp: p[0].to_time.to_i } timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
end
else
timeseries.each_with_object([]) do |p, arr|
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
end
end end
end end
@ -34,23 +41,16 @@ class V2::ReportBuilder
} }
end end
private def conversation_metrics
if params[:type].equal?(:account)
def scope conversations
case params[:type] else
when :account agent_metrics
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end end
end end
private
def inbox def inbox
@inbox ||= account.inboxes.find(params[:id]) @inbox ||= account.inboxes.find(params[:id])
end end
@ -68,7 +68,7 @@ class V2::ReportBuilder
end end
def get_grouped_values(object_scope) def get_grouped_values(object_scope)
object_scope.group_by_period( @grouped_values = object_scope.group_by_period(
params[:group_by] || DEFAULT_GROUP_BY, params[:group_by] || DEFAULT_GROUP_BY,
:created_at, :created_at,
default_value: 0, default_value: 0,
@ -78,47 +78,26 @@ class V2::ReportBuilder
) )
end end
def conversations_count def agent_metrics
(get_grouped_values scope.conversations).count users = @account.users
users = users.where(id: params[:user_id]) if params[:user_id].present?
users.each_with_object([]) do |user, arr|
@user = user
arr << {
user: { id: user.id, name: user.name, thumbnail: user.avatar_url },
metric: conversations
}
end
end end
def incoming_messages_count def conversations
(get_grouped_values scope.messages.incoming.unscope(:order)).count @open_conversations = scope.conversations.open
end first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
def outgoing_messages_count open: @open_conversations.count,
(get_grouped_values scope.messages.outgoing.unscope(:order)).count unattended: @open_conversations.count - first_response_count
end }
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
def resolutions_count metric
(get_grouped_values scope.conversations.resolved).count
end
def avg_first_response_time
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
end
def avg_resolution_time
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
end
def avg_resolution_time_summary
avg_rt = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
.average(:value)
return 0 if avg_rt.blank?
avg_rt
end
def avg_first_response_time_summary
avg_frt = scope.reporting_events
.where(name: 'first_response', created_at: range)
.average(:value)
return 0 if avg_frt.blank?
avg_frt
end end
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@agent_bot.destroy @agent_bot.destroy!
head :ok head :ok
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@agent.current_account_user.destroy @agent.current_account_user.destroy!
head :ok head :ok
end end

View file

@ -7,13 +7,28 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
end end
def create def create
@automation_rule = Current.account.automation_rules.create(automation_rules_permit) @automation_rule = Current.account.automation_rules.new(automation_rules_permit)
@automation_rule.actions = params[:actions]
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
@automation_rule.save!
process_attachments
@automation_rule
end end
def show; end def show; end
def update def update
@automation_rule.update(automation_rules_permit) ActiveRecord::Base.transaction do
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.save!
process_attachments
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end end
def destroy def destroy
@ -30,11 +45,20 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
private private
def process_attachments
return if params[:attachments].blank?
params[:attachments].each do |uploaded_attachment|
@automation_rule.files.attach(uploaded_attachment)
end
@automation_rule
end
def automation_rules_permit def automation_rules_permit
params.permit( params.permit(
:name, :description, :event_name, :account_id, :active, :name, :description, :event_name, :account_id, :active,
conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }], conditions: [:attribute_key, :filter_operator, :query_operator, { values: [] }],
actions: [:action_name, { action_params: [{}] }] actions: [:action_name, { action_params: [] }]
) )
end end

View file

@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', '')) koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
koala.exchange_access_token_info(omniauth_token)['access_token'] koala.exchange_access_token_info(omniauth_token)['access_token']
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
end end
def mark_already_existing_facebook_pages(data) def mark_already_existing_facebook_pages(data)

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@campaign.destroy @campaign.destroy!
head :ok head :ok
end end

View file

@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
end end
def destroy def destroy
@canned_response.destroy @canned_response.destroy!
head :ok head :ok
end end

View file

@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
end end
def destroy def destroy
@note.destroy @note.destroy!
head :ok head :ok
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
end end
def destroy def destroy
@custom_attribute_definition.destroy @custom_attribute_definition.destroy!
head :no_content head :no_content
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
end end
def destroy def destroy
@custom_filter.destroy @custom_filter.destroy!
head :no_content head :no_content
end end

View file

@ -73,7 +73,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@inbox.destroy @inbox.destroy!
head :ok head :ok
end end

View file

@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end end
def destroy def destroy
@hook.destroy @hook.destroy!
head :ok head :ok
end end

View file

@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
end end
def destroy def destroy
@hook.destroy @hook.destroy!
head :ok head :ok
end end

View file

@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
end end
def destroy def destroy
@category.destroy @category.destroy!
head :ok head :ok
end end

View file

@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba
end end
def destroy def destroy
@portal.destroy @portal.destroy!
head :ok head :ok
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@label.destroy @label.destroy!
head :ok head :ok
end end

View file

@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@team.destroy @team.destroy!
head :ok head :ok
end end

View file

@ -16,7 +16,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
end end
def destroy def destroy
@webhook.destroy @webhook.destroy!
head :ok head :ok
end end

View file

@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
def destroy def destroy
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
notification_subscription.destroy notification_subscription.destroy!
head :ok head :ok
end end

View file

@ -39,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController
browser: browser_params, browser: browser_params,
referer: permitted_params[:message][:referer_url], referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params initiated_at: timestamp_params
} },
custom_attributes: permitted_params[:custom_attributes].presence || {}
} }
end end
@ -52,16 +53,33 @@ class Api::V1::Widget::BaseController < ApplicationController
mergee_contact: @contact mergee_contact: @contact
).perform ).perform
else else
@contact.update!(email: email, name: contact_name) @contact.update!(email: email)
end
end
def update_contact_phone_number(phone_number)
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
if contact_with_phone_number
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_phone_number,
mergee_contact: @contact
).perform
else
@contact.update!(phone_number: phone_number)
end end
end end
def contact_email def contact_email
permitted_params[:contact][:email].downcase permitted_params[:contact][:email].downcase if permitted_params[:contact].present?
end end
def contact_name def contact_name
params[:contact][:name] || contact_email.split('@')[0] params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
end
def contact_phone_number
params[:contact][:phone_number]
end end
def browser_params def browser_params

View file

@ -7,12 +7,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def create def create
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
update_contact(contact_email) if @contact.email.blank? && contact_email.present? process_update_contact
@conversation = create_conversation @conversation = create_conversation
conversation.messages.create(message_params) conversation.messages.create(message_params)
end end
end end
def process_update_contact
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
@contact.update!(name: contact_name) if contact_name.present?
end
def update_last_seen def update_last_seen
head :ok && return if conversation.nil? head :ok && return if conversation.nil?
@ -45,7 +51,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def toggle_status def toggle_status
head :not_found && return if conversation.nil? return head :not_found if conversation.nil?
return head :forbidden unless @web_widget.end_conversation?
unless conversation.resolved? unless conversation.resolved?
conversation.status = :resolved conversation.status = :resolved
conversation.save conversation.save
@ -60,6 +69,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end end
def permitted_params def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id]) params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end end
end end

View file

@ -35,41 +35,54 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv' render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
end end
def conversations
return head :unprocessable_entity if params[:type].blank?
render json: conversation_metrics
end
private private
def check_authorization def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator? raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end end
def current_summary_params def common_params
{ {
type: params[:type].to_sym, type: params[:type].to_sym,
id: params[:id], id: params[:id],
since: range[:current][:since], group_by: params[:group_by],
until: range[:current][:until], business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
group_by: params[:group_by]
} }
end end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params def previous_summary_params
{ common_params.merge({
type: params[:type].to_sym, since: range[:previous][:since],
id: params[:id], until: range[:previous][:until]
since: range[:previous][:since], })
until: range[:previous][:until],
group_by: params[:group_by]
}
end end
def report_params def report_params
common_params.merge({
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone_offset: params[:timezone_offset]
})
end
def conversation_params
{ {
metric: params[:metric],
type: params[:type].to_sym, type: params[:type].to_sym,
since: params[:since], user_id: params[:user_id]
until: params[:until],
id: params[:id],
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
} }
end end
@ -91,4 +104,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary summary
end end
def conversation_metrics
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
end
end end

View file

@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController
end end
def destroy def destroy
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy @resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
head :ok head :ok
end end

View file

@ -7,8 +7,8 @@ class Platform::Api::V1::UsersController < PlatformController
def create def create
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params)) @resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
@resource.skip_confirmation!
@resource.save! @resource.save!
@resource.confirm
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource) @platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
end end

View file

@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id) redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end end
rescue StandardError => e rescue StandardError => e
Rails.logger.info e Rails.logger.error e
redirect_to twitter_app_redirect_url redirect_to twitter_app_redirect_url
end end

View file

@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry]) ::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
render json: :ok render json: :ok
else else
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}") Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
head :unprocessable_entity head :unprocessable_entity
end end
end end

View file

@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base
before_action :ensure_widget_position before_action :ensure_widget_position
before_action :ensure_widget_type before_action :ensure_widget_type
before_action :ensure_widget_style before_action :ensure_widget_style
before_action :ensure_dark_mode
def index def index
render render
@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base
@widget_style = params[:widget_style] || 'standard' @widget_style = params[:widget_style] || 'standard'
end end
def ensure_dark_mode
@dark_mode = params[:dark_mode] || 'light'
end
def ensure_widget_position def ensure_widget_position
@widget_position = params[:position] || 'left' @widget_position = params[:position] || 'left'
end end

View file

@ -0,0 +1,15 @@
class EmailChannelFinder
def initialize(email_object)
@email_object = email_object
end
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
break if channel.present?
end
channel
end
end

View file

@ -14,7 +14,7 @@ module Api::V1::InboxesHelper
Mail.defaults do Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address], retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port], port: channel_data[:imap_port],
user_name: channel_data[:imap_email], user_name: channel_data[:imap_login],
password: channel_data[:imap_password], password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] } enable_ssl: channel_data[:imap_enable_ssl] }
end end
@ -29,8 +29,12 @@ module Api::V1::InboxesHelper
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port]) smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
set_smtp_encryption(channel_data, smtp) set_smtp_encryption(channel_data, smtp)
check_smtp_connection(channel_data, smtp)
end
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login) def check_smtp_connection(channel_data, smtp)
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish unless smtp&.nil? smtp.finish unless smtp&.nil?
end end

View file

@ -0,0 +1,68 @@
module ReportHelper
private
def scope
case params[:type]
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def conversations_count
(get_grouped_values scope.conversations).count
end
def incoming_messages_count
(get_grouped_values scope.messages.incoming.unscope(:order)).count
end
def outgoing_messages_count
(get_grouped_values scope.messages.outgoing.unscope(:order)).count
end
def resolutions_count
(get_grouped_values scope.conversations.resolved).count
end
def avg_first_response_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response'))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end
def avg_resolution_time
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved'))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end
def avg_resolution_time_summary
reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_rt.blank?
avg_rt
end
def avg_first_response_time_summary
reporting_events = scope.reporting_events
.where(name: 'first_response', created_at: range)
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_frt.blank?
avg_frt
end
end

View file

@ -0,0 +1,50 @@
module ReportingEventHelper
def business_hours(inbox, from, to)
return 0 unless inbox.working_hours_enabled?
inbox_working_hours = configure_working_hours(inbox.working_hours)
return 0 if inbox_working_hours.blank?
# Configure working hours
WorkingHours::Config.working_hours = inbox_working_hours
# Configure timezone
WorkingHours::Config.time_zone = inbox.timezone
# Use inbox timezone to change from & to values.
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
end
private
def configure_working_hours(working_hours)
working_hours.each_with_object({}) do |working_hour, object|
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
end
end
def day(day_of_week)
week_days = {
0 => :sun,
1 => :mon,
2 => :tue,
3 => :wed,
4 => :thu,
5 => :fri,
6 => :sat
}
week_days[day_of_week]
end
def working_hour_range(working_hour)
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
end
def format_time(hour, minute)
hour = hour < 10 ? "0#{hour}" : hour
minute = minute < 10 ? "0#{minute}" : minute
"#{hour}:#{minute}"
end
end

View file

@ -1,5 +1,5 @@
<template> <template>
<div id="app" class="app-wrapper app-root"> <div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
<update-banner :latest-chatwoot-version="latestChatwootVersion" /> <update-banner :latest-chatwoot-version="latestChatwootVersion" />
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<router-view></router-view> <router-view></router-view>
@ -11,21 +11,28 @@
<woot-snackbar-box /> <woot-snackbar-box />
<network-notification /> <network-notification />
</div> </div>
<loading-state v-else />
</template> </template>
<script> <script>
import { accountIdFromPathname } from './helper/URLHelper';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification'; import NetworkNotification from './components/NetworkNotification';
import UpdateBanner from './components/app/UpdateBanner.vue'; import UpdateBanner from './components/app/UpdateBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer'; import WootSnackbarBox from './components/SnackbarContainer';
import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
export default { export default {
name: 'App', name: 'App',
components: { components: {
AddAccountModal, AddAccountModal,
LoadingState,
NetworkNotification, NetworkNotification,
UpdateBanner, UpdateBanner,
WootSnackbarBox, WootSnackbarBox,
@ -43,13 +50,12 @@ export default {
getAccount: 'accounts/getAccount', getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get', globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
currentAccountId: 'getCurrentAccountId',
}), }),
hasAccounts() { hasAccounts() {
return ( const { accounts = [] } = this.currentUser || {};
this.currentUser && return accounts.length > 0;
this.currentUser.accounts &&
this.currentUser.accounts.length !== 0
);
}, },
}, },
@ -58,32 +64,37 @@ export default {
if (!this.hasAccounts) { if (!this.hasAccounts) {
this.showAddAccountModal = true; this.showAddAccountModal = true;
} }
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
}, },
}, },
mounted() { mounted() {
this.$store.dispatch('setUser');
this.setLocale(window.chatwootConfig.selectedLocale); this.setLocale(window.chatwootConfig.selectedLocale);
this.initializeAccount();
}, },
methods: { methods: {
setLocale(locale) { setLocale(locale) {
this.$root.$i18n.locale = locale; this.$root.$i18n.locale = locale;
}, },
async initializeAccount() { async initializeAccount() {
const { pathname } = window.location; await this.$store.dispatch('accounts/get');
const accountId = accountIdFromPathname(pathname); const {
locale,
if (accountId) { latest_chatwoot_version: latestChatwootVersion,
await this.$store.dispatch('accounts/get'); } = this.getAccount(this.currentAccountId);
const { const { pubsub_token: pubsubToken } = this.currentUser || {};
locale, this.setLocale(locale);
latest_chatwoot_version: latestChatwootVersion, this.latestChatwootVersion = latestChatwootVersion;
} = this.getAccount(accountId); vueActionCable.init(pubsubToken);
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
}
}, },
}, },
}; };

View file

@ -1,6 +1,4 @@
/* eslint no-console: 0 */
/* global axios */ /* global axios */
/* eslint no-undef: "error" */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import endPoints from './endPoints'; import endPoints from './endPoints';
@ -61,41 +59,15 @@ export default {
}); });
return fetchPromise; return fetchPromise;
}, },
hasAuthCookie() {
isLoggedIn() { return !!Cookies.getJSON('cw_d_session_info');
const hasAuthCookie = !!Cookies.getJSON('auth_data');
const hasUserCookie = !!Cookies.getJSON('user');
return hasAuthCookie && hasUserCookie;
}, },
isAdmin() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user').role === 'administrator';
}
return false;
},
getAuthData() { getAuthData() {
if (this.isLoggedIn()) { if (this.hasAuthCookie()) {
return Cookies.getJSON('auth_data'); return Cookies.getJSON('cw_d_session_info');
} }
return false; return false;
}, },
getPubSubToken() {
if (this.isLoggedIn()) {
const user = Cookies.getJSON('user') || {};
const { pubsub_token: pubsubToken } = user;
return pubsubToken;
}
return null;
},
getCurrentUser() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user');
}
return null;
},
verifyPasswordToken({ confirmationToken }) { verifyPasswordToken({ confirmationToken }) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios axios

View file

@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' }); super('reports', { accountScoped: true, apiVersion: 'v2' });
} }
getReports(metric, since, until, type = 'account', id, group_by) { getReports(
metric,
since,
until,
type = 'account',
id,
group_by,
business_hours
) {
return axios.get(`${this.url}`, { return axios.get(`${this.url}`, {
params: { params: {
metric, metric,
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
type, type,
id, id,
group_by, group_by,
business_hours,
timezone_offset: getTimeOffset(), timezone_offset: getTimeOffset(),
}, },
}); });
} }
getSummary(since, until, type = 'account', id, group_by) { getSummary(since, until, type = 'account', id, group_by, business_hours) {
return axios.get(`${this.url}/summary`, { return axios.get(`${this.url}/summary`, {
params: { params: {
since, since,
@ -30,6 +39,7 @@ class ReportsAPI extends ApiClient {
type, type,
id, id,
group_by, group_by,
business_hours,
}, },
}); });
} }

View file

@ -51,10 +51,6 @@
background-color: var(--white); background-color: var(--white);
} }
.text-y-800 {
color: var(--y-800);
}
.text-ellipsis { .text-ellipsis {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;

View file

@ -62,13 +62,13 @@ $default-button-height: 4.0rem;
} }
&.warning { &.warning {
@include button-style(var(--y-100), var(--y-200), var(--y-900)); @include button-style(var(--y-100), var(--y-200), var(--y-700));
} }
} }
&.clear { &.clear {
&.warning { &.warning {
color: var(--y-800); color: var(--y-600);
} }
&.button--only-icon:hover { &.button--only-icon:hover {
@ -87,7 +87,7 @@ $default-button-height: 4.0rem;
} }
&.warning { &.warning {
background: var(--y-100); background: var(--y-50);
} }
} }
} }

View file

@ -18,6 +18,13 @@
font-size: $font-size-small; font-size: $font-size-small;
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
color: $color-heading; color: $color-heading;
display: flex;
align-items: center;
}
.info-icon {
color: var(--b-400);
margin-left: var(--space-micro);
} }
.metric-wrap { .metric-wrap {
@ -71,5 +78,10 @@
font-size: $font-size-default; font-size: $font-size-default;
color: $color-gray; color: $color-gray;
} }
.business-hours {
margin: $space-normal;
text-align: center;
}
} }
} }

View file

@ -25,3 +25,21 @@
align-items: center; align-items: center;
display: flex; display: flex;
} }
.business-hours {
align-items: center;
display: flex;
justify-content: end;
margin-bottom: var(--space-normal);
margin-left: auto;
padding-right: var(--space-normal);
}
.business-hours-text {
font-size: var(--font-size-small);
}
.switch {
margin-bottom: var(--space-zero);
margin-left: var(--space-small);
}

View file

@ -15,9 +15,9 @@
{{ $t('NETWORK.BUTTON.REFRESH') }} {{ $t('NETWORK.BUTTON.REFRESH') }}
</woot-button> </woot-button>
<woot-button <woot-button
variant="clear" variant="smooth"
size="small" size="small"
color-scheme="secondary" color-scheme="warning"
icon="dismiss-circle" icon="dismiss-circle"
@click="closeNotification" @click="closeNotification"
> >

View file

@ -7,6 +7,10 @@
<p class="sub-head"> <p class="sub-head">
{{ subTitle }} {{ subTitle }}
</p> </p>
<p v-if="note">
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
{{ note }}
</p>
</div> </div>
<div class="medium-6 small-12"> <div class="medium-6 small-12">
<slot></slot> <slot></slot>
@ -25,6 +29,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
note: {
type: String,
default: '',
},
}, },
}; };
</script> </script>
@ -46,5 +54,9 @@ export default {
.title--section { .title--section {
padding-right: var(--space-large); padding-right: var(--space-large);
} }
.note {
font-weight: var(--font-weight-bold);
}
} }
</style> </style>

View file

@ -57,3 +57,13 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
button:disabled {
opacity: 1;
background-color: var(--w-100);
&:hover {
background-color: var(--w-100);
}
}
</style>

View file

@ -53,7 +53,7 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability', getCurrentUserAvailability: 'getCurrentUserAvailability',
getCurrentAccountId: 'getCurrentAccountId', currentAccountId: 'getCurrentAccountId',
}), }),
availabilityDisplayLabel() { availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex( const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -63,9 +63,6 @@ export default {
availabilityIndex availabilityIndex
]; ];
}, },
currentAccountId() {
return this.getCurrentAccountId;
},
currentUserAvailability() { currentUserAvailability() {
return this.getCurrentUserAvailability; return this.getCurrentUserAvailability;
}, },

View file

@ -8,6 +8,7 @@
:active-menu-item="activePrimaryMenu.key" :active-menu-item="activePrimaryMenu.key"
@toggle-accounts="toggleAccountModal" @toggle-accounts="toggleAccountModal"
@key-shortcut-modal="toggleKeyShortcutModal" @key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/> />
<secondary-sidebar <secondary-sidebar
:account-id="accountId" :account-id="accountId"
@ -176,6 +177,9 @@ export default {
showAddLabelPopup() { showAddLabelPopup() {
this.$emit('show-add-label-popup'); this.$emit('show-add-label-popup');
}, },
openNotificationPanel() {
this.$emit('open-notification-panel');
},
}, },
}; };
</script> </script>

View file

@ -1,19 +1,21 @@
<template> <template>
<div class="notifications-link"> <div class="notifications-link">
<primary-nav-item <woot-button
name="NOTIFICATIONS" class-names="notifications-link--button"
icon="alert" variant="clear"
:to="`/app/accounts/${accountId}/notifications`" color-scheme="secondary"
:count="unreadCount" :class="{ 'is-active': isNotificationPanelActive }"
/> @click="openNotificationPanel"
>
<fluent-icon icon="alert" />
<span v-if="unreadCount" class="badge warning">{{ unreadCount }}</span>
</woot-button>
</div> </div>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import PrimaryNavItem from './PrimaryNavItem';
export default { export default {
components: { PrimaryNavItem },
computed: { computed: {
...mapGetters({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
@ -28,8 +30,17 @@ export default {
? `${this.notificationMetadata.unreadCount}` ? `${this.notificationMetadata.unreadCount}`
: '99+'; : '99+';
}, },
isNotificationPanelActive() {
return this.$route.name === 'notifications_index';
},
},
methods: {
openNotificationPanel() {
if (this.$route.name !== 'notifications_index') {
this.$emit('open-notification-panel');
}
},
}, },
methods: {},
}; };
</script> </script>
@ -37,4 +48,32 @@ export default {
.notifications-link { .notifications-link {
margin-bottom: var(--space-small); margin-bottom: var(--space-small);
} }
.badge {
position: absolute;
right: var(--space-minus-smaller);
top: var(--space-minus-smaller);
}
.notifications-link--button {
display: flex;
position: relative;
border-radius: var(--border-radius-large);
border: 1px solid transparent;
color: var(--s-600);
margin: var(--space-small) 0;
&:hover {
background: var(--w-50);
color: var(--s-600);
}
&:focus {
border-color: var(--w-500);
}
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
</style> </style>

View file

@ -16,7 +16,7 @@
/> />
</nav> </nav>
<div class="menu vertical user-menu"> <div class="menu vertical user-menu">
<notification-bell /> <notification-bell @open-notification-panel="openNotificationPanel" />
<agent-details @toggle-menu="toggleOptions" /> <agent-details @toggle-menu="toggleOptions" />
<options-menu <options-menu
:show="showOptionsMenu" :show="showOptionsMenu"
@ -83,6 +83,9 @@ export default {
toggleSupportChatWindow() { toggleSupportChatWindow() {
window.$chatwoot.toggle(); window.$chatwoot.toggle();
}, },
openNotificationPanel() {
this.$emit('open-notification-panel');
},
}, },
}; };
</script> </script>

View file

@ -85,14 +85,20 @@ export default {
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`), toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
toStateName: 'settings_inbox_new', toStateName: 'settings_inbox_new',
newLinkRouteName: 'settings_inbox_new', newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({ children: this.inboxes
id: inbox.id, .map(inbox => ({
label: inbox.name, id: inbox.id,
truncateLabel: true, label: inbox.name,
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`), truncateLabel: true,
type: inbox.channel_type, toState: frontendURL(
phoneNumber: inbox.phone_number, `accounts/${this.accountId}/inbox/${inbox.id}`
})), ),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
}))
.sort((a, b) =>
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
),
}; };
}, },
labelSection() { labelSection() {

View file

@ -14,6 +14,10 @@ const i18nConfig = new VueI18n({
messages: i18n, messages: i18n,
}); });
const $route = {
name: 'notifications_index',
};
describe('notificationBell', () => { describe('notificationBell', () => {
const accountId = 1; const accountId = 1;
const notificationMetadata = { unreadCount: 19 }; const notificationMetadata = { unreadCount: 19 };
@ -45,24 +49,40 @@ describe('notificationBell', () => {
}); });
it('it should return unread count 19 ', () => { it('it should return unread count 19 ', () => {
const notificationBell = shallowMount(NotificationBell, { const wrapper = shallowMount(NotificationBell, {
store,
localVue, localVue,
i18n: i18nConfig, i18n: i18nConfig,
store,
mocks: {
$route,
},
}); });
expect(wrapper.vm.unreadCount).toBe('19');
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('19');
}); });
it('it should return unread count 99+ ', async () => { it('it should return unread count 99+ ', async () => {
notificationMetadata.unreadCount = 101; notificationMetadata.unreadCount = 100;
const wrapper = shallowMount(NotificationBell, {
localVue,
i18n: i18nConfig,
store,
mocks: {
$route,
},
});
expect(wrapper.vm.unreadCount).toBe('99+');
});
it('isNotificationPanelActive', async () => {
const notificationBell = shallowMount(NotificationBell, { const notificationBell = shallowMount(NotificationBell, {
store, store,
localVue, localVue,
i18n: i18nConfig, i18n: i18nConfig,
mocks: {
$route,
},
}); });
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
expect(statusViewTitle.vm.count).toBe('99+'); expect(notificationBell.vm.isNotificationPanelActive).toBe(true);
}); });
}); });

View file

@ -112,10 +112,10 @@ export default {
} }
&.warning { &.warning {
background: var(--y-800); background: var(--y-600);
color: var(--s-600); color: var(--y-500);
a { a {
color: var(--s-600); color: var(--y-500);
} }
} }

View file

@ -157,7 +157,7 @@ export default {
&.warning { &.warning {
background: var(--y-100); background: var(--y-100);
color: var(--y-900); color: var(--y-900);
border: 1px solid var(--y-300); border: 1px solid var(--y-200);
a { a {
color: var(--y-900); color: var(--y-900);
} }

View file

@ -1,54 +1,66 @@
<template> <template>
<label class="switch" :class="classObject"> <button
<input type="button"
:id="id" class="toggle-button"
v-model="value" :class="{ active: value }"
class="switch-input" role="switch"
:name="name" :aria-checked="value.toString()"
:disabled="disabled" @click="onClick"
type="checkbox" >
/> <span aria-hidden="true" :class="{ active: value }"></span>
<div class="switch-paddle" :for="name"> </button>
<span class="show-for-sr">on off</span>
</div>
</label>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
disabled: Boolean, value: { type: Boolean, default: false },
type: { type: String, default: '' },
size: { type: String, default: '' },
checked: Boolean,
name: { type: String, default: '' },
id: { type: String, default: '' },
}, },
data() { methods: {
return { onClick() {
value: null, this.$emit('input', !this.value);
};
},
computed: {
classObject() {
const { type, size, value } = this;
return {
[`is-${type}`]: type,
[`${size}`]: size,
checked: value,
};
}, },
}, },
watch: {
value(val) {
this.$emit('input', val);
},
},
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', (this.value = !!this.checked));
},
}; };
</script> </script>
<style lang="scss" scoped>
.toggle-button {
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
background-color: var(--s-200);
border-radius: var(--border-radius-large);
border: 2px solid transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 19px;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 34px;
&.active {
background-color: var(--w-500);
}
span {
--space-one-point-five: 1.5rem;
background-color: var(--white);
border-radius: 100%;
box-shadow: var(--toggle-button-box-shadow);
display: inline-block;
height: var(--space-one-point-five);
transform: translate(0, 0);
transition-duration: 200ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: var(--space-one-point-five);
&.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
}
}
</style>

View file

@ -7,7 +7,8 @@
<select <select
v-model="action_name" v-model="action_name"
class="action__question" class="action__question"
@change="resetFilter()" :class="{ 'full-width': !showActionInput }"
@change="resetAction()"
> >
<option <option
v-for="attribute in actionTypes" v-for="attribute in actionTypes"
@ -17,20 +18,39 @@
{{ attribute.label }} {{ attribute.label }}
</option> </option>
</select> </select>
<div class="filter__answer--wrap"> <div v-if="showActionInput" class="filter__answer--wrap">
<div class="multiselect-wrap--small"> <div v-if="inputType">
<multiselect <div
v-if="inputType === 'multi_select'"
class="multiselect-wrap--small"
>
<multiselect
v-model="action_params"
track-by="id"
label="name"
:placeholder="'Select'"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="dropdownValues"
:allow-empty="false"
/>
</div>
<input
v-else-if="inputType === 'email'"
v-model="action_params" v-model="action_params"
track-by="id" type="email"
label="name" class="answer--text-input"
:placeholder="'Select'" placeholder="Enter email"
:multiple="true" />
selected-label <input
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')" v-else-if="inputType === 'url'"
deselect-label="" v-model="action_params"
:max-height="160" type="url"
:options="dropdownValues" class="answer--text-input"
:allow-empty="false" placeholder="Enter url"
/> />
</div> </div>
</div> </div>
@ -41,6 +61,18 @@
@click="removeAction" @click="removeAction"
/> />
</div> </div>
<automation-action-team-message-input
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<textarea
v-if="inputType === 'textarea'"
v-model="action_params"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
></textarea>
<p <p
v-if="v.action_params.$dirty && v.action_params.$error" v-if="v.action_params.$dirty && v.action_params.$error"
class="filter-error" class="filter-error"
@ -51,7 +83,11 @@
</template> </template>
<script> <script>
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
export default { export default {
components: {
AutomationActionTeamMessageInput,
},
props: { props: {
value: { value: {
type: Object, type: Object,
@ -69,6 +105,10 @@ export default {
type: Object, type: Object,
default: () => null, default: () => null,
}, },
showActionInput: {
type: Boolean,
default: true,
},
}, },
computed: { computed: {
action_name: { action_name: {
@ -91,13 +131,17 @@ export default {
this.$emit('input', { ...payload, action_params: value }); this.$emit('input', { ...payload, action_params: value });
}, },
}, },
inputType() {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
}, },
methods: { methods: {
removeAction() { removeAction() {
this.$emit('removeAction'); this.$emit('removeAction');
}, },
resetFilter() { resetAction() {
this.$emit('resetFilter'); this.$emit('resetAction');
}, },
}, },
}; };
@ -136,6 +180,10 @@ export default {
max-width: 50%; max-width: 50%;
} }
.action__question.full-width {
max-width: 100%;
}
.filter__answer--wrap { .filter__answer--wrap {
margin-right: var(--space-smaller); margin-right: var(--space-smaller);
flex-grow: 1; flex-grow: 1;
@ -179,4 +227,7 @@ export default {
.multiselect { .multiselect {
margin-bottom: var(--space-zero); margin-bottom: var(--space-zero);
} }
.action-message {
margin: var(--space-small) 0 0;
}
</style> </style>

View file

@ -0,0 +1,62 @@
<template>
<div>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedTeams"
track-by="id"
label="name"
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="teams"
:allow-empty="false"
@input="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
></textarea>
</div>
</div>
</template>
<script>
export default {
// The value types are dynamic, hence prop validation removed to work with our action schema
// eslint-disable-next-line vue/require-prop-types
props: ['teams', 'value'],
data() {
return {
selectedTeams: [],
message: '',
};
},
mounted() {
const { team_ids: teamIds } = this.value;
this.selectedTeams = teamIds;
this.message = this.value.message;
},
methods: {
updateValue() {
this.$emit('input', {
team_ids: this.selectedTeams.map(team => team.id),
message: this.message,
});
},
},
};
</script>
<style scoped>
.multiselect {
margin: var(--space-smaller) var(--space-zero);
}
textarea {
margin-bottom: var(--space-zero);
}
</style>

View file

@ -5,7 +5,14 @@
@click="onClick(index)" @click="onClick(index)"
> >
<h3 class="heading"> <h3 class="heading">
{{ heading }} <span>{{ heading }}</span>
<fluent-icon
v-if="infoText"
v-tooltip="infoText"
size="14"
icon="info"
class="info-icon"
/>
</h3> </h3>
<div class="metric-wrap"> <div class="metric-wrap">
<h4 class="metric"> <h4 class="metric">
@ -22,6 +29,7 @@
export default { export default {
props: { props: {
heading: { type: String, default: '' }, heading: { type: String, default: '' },
infoText: { type: String, default: '' },
point: { type: [Number, String], default: '' }, point: { type: [Number, String], default: '' },
trend: { type: Number, default: null }, trend: { type: Number, default: null },
index: { type: Number, default: null }, index: { type: Number, default: null },

View file

@ -209,7 +209,7 @@ export default {
} }
.user-online-status--busy { .user-online-status--busy {
background: var(--y-700); background: var(--y-500);
} }
.user-online-status--offline { .user-online-status--offline {

View file

@ -163,13 +163,13 @@ export default {
&:hover, &:hover,
&:active { &:active {
color: var(--y-800); color: var(--y-700);
} }
} }
} }
.button--note { .button--note {
color: var(--y-900); color: var(--y-600);
} }
.action-wrap { .action-wrap {

View file

@ -3,7 +3,7 @@ import { Bar } from 'vue-chartjs';
const fontFamily = const fontFamily =
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
const chartOptions = { const defaultChartOptions = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
legend: { legend: {
@ -11,10 +11,14 @@ const chartOptions = {
fontFamily, fontFamily,
}, },
}, },
datasets: {
bar: {
barPercentage: 1.0,
},
},
scales: { scales: {
xAxes: [ xAxes: [
{ {
barPercentage: 1.1,
ticks: { ticks: {
fontFamily, fontFamily,
}, },
@ -39,8 +43,20 @@ const chartOptions = {
export default { export default {
extends: Bar, extends: Bar,
props: ['collection'], props: {
collection: {
type: Object,
default: () => {},
},
chartOptions: {
type: Object,
default: () => {},
},
},
mounted() { mounted() {
this.renderChart(this.collection, chartOptions); this.renderChart(this.collection, {
...defaultChartOptions,
...this.chartOptions,
});
}, },
}; };

View file

@ -23,7 +23,7 @@ export default {
background: var(--s-500); background: var(--s-500);
} }
&__busy { &__busy {
background: var(--y-700); background: var(--y-500);
} }
} }
</style> </style>

View file

@ -14,8 +14,8 @@
<fluent-icon <fluent-icon
v-if="!isHMACVerified" v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')" v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
class="text-y-800"
size="14" size="14"
class="hmac-warning__icon"
icon="warning" icon="warning"
/> />
</h3> </h3>
@ -181,7 +181,11 @@ export default {
.snoozed--display-text { .snoozed--display-text {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--y-900); color: var(--y-600);
} }
} }
.hmac-warning__icon {
color: var(--y-600);
}
</style> </style>

View file

@ -207,7 +207,11 @@ export default {
} }
} }
return ( return (
this.formatMessage(this.data.content, this.isATweet) + botMessageContent this.formatMessage(
this.data.content,
this.isATweet,
this.data.private
) + botMessageContent
); );
}, },
contentAttributes() { contentAttributes() {

View file

@ -83,7 +83,6 @@
</div> </div>
</div> </div>
<reply-box <reply-box
v-on-clickaway="closePopoutReplyBox"
:conversation-id="currentChat.id" :conversation-id="currentChat.id"
:is-a-tweet="isATweet" :is-a-tweet="isATweet"
:selected-tweet="selectedTweet" :selected-tweet="selectedTweet"
@ -109,7 +108,6 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper'; import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers'; import { isEscape } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins'; import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { mixin as clickaway } from 'vue-clickaway';
export default { export default {
components: { components: {
@ -117,7 +115,7 @@ export default {
ReplyBox, ReplyBox,
Banner, Banner,
}, },
mixins: [conversationMixin, inboxMixin, eventListenerMixins, clickaway], mixins: [conversationMixin, inboxMixin, eventListenerMixins],
props: { props: {
isContactPanelOpen: { isContactPanelOpen: {
type: Boolean, type: Boolean,

View file

@ -8,7 +8,7 @@
<div class="input-group-field"> <div class="input-group-field">
<woot-input <woot-input
v-model.trim="$v.ccEmailsVal.$model" v-model.trim="$v.ccEmailsVal.$model"
type="email" type="text"
:class="{ error: $v.ccEmailsVal.$error }" :class="{ error: $v.ccEmailsVal.$error }"
:placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')" :placeholder="$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.CC.PLACEHOLDER')"
@blur="onBlur" @blur="onBlur"
@ -35,7 +35,7 @@
<div class="input-group-field"> <div class="input-group-field">
<woot-input <woot-input
v-model.trim="$v.bccEmailsVal.$model" v-model.trim="$v.bccEmailsVal.$model"
type="email" type="text"
:class="{ error: $v.bccEmailsVal.$error }" :class="{ error: $v.bccEmailsVal.$error }"
:placeholder=" :placeholder="
$t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER') $t('CONVERSATION.REPLYBOX.EMAIL_HEAD.BCC.PLACEHOLDER')

View file

@ -1,4 +1,3 @@
/* eslint no-console: 0 */
import Auth from '../api/auth'; import Auth from '../api/auth';
const parseErrorCode = error => Promise.reject(error); const parseErrorCode = error => Promise.reject(error);
@ -7,7 +6,7 @@ export default axios => {
const { apiHost = '' } = window.chatwootConfig || {}; const { apiHost = '' } = window.chatwootConfig || {};
const wootApi = axios.create({ baseURL: `${apiHost}/` }); const wootApi = axios.create({ baseURL: `${apiHost}/` });
// Add Auth Headers to requests if logged in // Add Auth Headers to requests if logged in
if (Auth.isLoggedIn()) { if (Auth.hasAuthCookie()) {
const { const {
'access-token': accessToken, 'access-token': accessToken,
'token-type': tokenType, 'token-type': tokenType,

View file

@ -14,6 +14,9 @@ export const getLoginRedirectURL = (ssoAccountId, user) => {
if (ssoAccount) { if (ssoAccount) {
return frontendURL(`accounts/${ssoAccountId}/dashboard`); return frontendURL(`accounts/${ssoAccountId}/dashboard`);
} }
if (accounts.length) {
return frontendURL(`accounts/${accounts[0].id}/dashboard`);
}
return DEFAULT_REDIRECT_URL; return DEFAULT_REDIRECT_URL;
}; };
@ -41,15 +44,6 @@ export const conversationUrl = ({
return url; return url;
}; };
export const accountIdFromPathname = pathname => {
const isInsideAccountScopedURLs = pathname.includes('/app/accounts');
const urlParam = pathname.split('/')[3];
// eslint-disable-next-line no-restricted-globals
const isScoped = isInsideAccountScopedURLs && !isNaN(urlParam);
const accountId = isScoped ? Number(urlParam) : '';
return accountId;
};
export const isValidURL = value => { export const isValidURL = value => {
/* eslint-disable no-useless-escape */ /* eslint-disable no-useless-escape */
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm; const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;

View file

@ -22,6 +22,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.deleted': this.onContactDelete, 'contact.deleted': this.onContactDelete,
'contact.updated': this.onContactUpdate, 'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned, 'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
}; };
} }
@ -134,17 +135,14 @@ class ActionCableConnector extends BaseActionCableConnector {
onContactUpdate = data => { onContactUpdate = data => {
this.app.$store.dispatch('contacts/updateContact', data); this.app.$store.dispatch('contacts/updateContact', data);
}; };
onNotificationCreated = data => {
this.app.$store.dispatch('notifications/addNotification', data);
};
} }
export default { export default {
init() { init(pubsubToken) {
if (AuthAPI.isLoggedIn()) { return new ActionCableConnector(window.WOOT, pubsubToken);
const actionCable = new ActionCableConnector(
window.WOOT,
AuthAPI.getPubSubToken()
);
return actionCable;
}
return null;
}, },
}; };

View file

@ -1,8 +1,19 @@
const formatArray = params => {
if (params.length <= 0) {
params = [];
} else if (params.every(elem => typeof elem === 'string')) {
params = [...params];
} else {
params = params.map(val => val.id);
}
return params;
};
const generatePayload = data => { const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data)); const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => { let payload = actions.map(item => {
if (Array.isArray(item.action_params)) { if (Array.isArray(item.action_params)) {
item.action_params = item.action_params.map(val => val.id); item.action_params = formatArray(item.action_params);
} else if (typeof item.values === 'object') { } else if (typeof item.values === 'object') {
item.action_params = [item.action_params.id]; item.action_params = [item.action_params.id];
} else if (!item.action_params) { } else if (!item.action_params) {

View file

@ -44,7 +44,7 @@ export const getPushSubscriptionPayload = subscription => ({
}); });
export const sendRegistrationToServer = subscription => { export const sendRegistrationToServer = subscription => {
if (auth.isLoggedIn()) { if (auth.hasAuthCookie()) {
return NotificationSubscriptions.create( return NotificationSubscriptions.create(
getPushSubscriptionPayload(subscription) getPushSubscriptionPayload(subscription)
); );

View file

@ -1,7 +1,6 @@
import { import {
frontendURL, frontendURL,
conversationUrl, conversationUrl,
accountIdFromPathname,
isValidURL, isValidURL,
getLoginRedirectURL, getLoginRedirectURL,
} from '../URLHelper'; } from '../URLHelper';
@ -39,18 +38,6 @@ describe('#URL Helpers', () => {
}); });
}); });
describe('accountIdFromPathname', () => {
it('should return account id if accont scoped url is passed', () => {
expect(accountIdFromPathname('/app/accounts/1/settings/general')).toBe(1);
});
it('should return empty string if accont scoped url not is passed', () => {
expect(accountIdFromPathname('/app/accounts/settings/general')).toBe('');
});
it('should return empty string if empty string is passed', () => {
expect(accountIdFromPathname('')).toBe('');
});
});
describe('isValidURL', () => { describe('isValidURL', () => {
it('should return true if valid url is passed', () => { it('should return true if valid url is passed', () => {
expect(isValidURL('https://chatwoot.com')).toBe(true); expect(isValidURL('https://chatwoot.com')).toBe(true);
@ -75,7 +62,7 @@ describe('#URL Helpers', () => {
getLoginRedirectURL('7500', { getLoginRedirectURL('7500', {
accounts: [{ id: '7501', name: 'Test Account 7501' }], accounts: [{ id: '7501', name: 'Test Account 7501' }],
}) })
).toBe('/app/'); ).toBe('/app/accounts/7501/dashboard');
expect(getLoginRedirectURL('7500', null)).toBe('/app/'); expect(getLoginRedirectURL('7500', null)).toBe('/app/');
}); });
}); });

View file

@ -89,7 +89,9 @@
"DELETE_MESSAGE": "You need to have atleast one condition to save" "DELETE_MESSAGE": "You need to have atleast one condition to save"
}, },
"ACTION": { "ACTION": {
"DELETE_MESSAGE": "You need to have atleast one action to save" "DELETE_MESSAGE": "You need to have atleast one action to save",
"TEAM_MESSAGE_INPUT_PLACEHOLDER": "Enter your message here",
"TEAM_DROPDOWN_PLACEHOLDER": "Select teams"
}, },
"TOGGLE": { "TOGGLE": {
"ACTIVATION_TITLE": "Activate Automation Rule", "ACTIVATION_TITLE": "Activate Automation Rule",

View file

@ -70,6 +70,14 @@
"SUCCESS_MESSAGE": "Contacts saved successfully", "SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again" "ERROR_MESSAGE": "There was an error, please try again"
}, },
"DELETE_NOTE": {
"CONFIRM":{
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you want sure to delete this note?",
"YES": "Yes, Delete it",
"NO": "No, Keep it"
}
},
"DELETE_CONTACT": { "DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact", "BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact", "TITLE": "Delete contact",

View file

@ -60,6 +60,13 @@
"NOTIFICATIONS_PAGE": { "NOTIFICATIONS_PAGE": {
"HEADER": "Notifications", "HEADER": "Notifications",
"MARK_ALL_DONE": "Mark All Done", "MARK_ALL_DONE": "Mark All Done",
"DELETE_TITLE": "deleted",
"UNREAD_NOTIFICATION": {
"TITLE": "Unread Notifications",
"ALL_NOTIFICATIONS": "View all notifications",
"LOADING_UNREAD_MESSAGE": "Loading unread notifications...",
"EMPTY_MESSAGE": "You have no unread notifications"
},
"LIST": { "LIST": {
"LOADING_MESSAGE": "Loading notifications...", "LOADING_MESSAGE": "Loading notifications...",
"404": "No Notifications", "404": "No Notifications",

View file

@ -395,7 +395,8 @@
"FEATURES": { "FEATURES": {
"LABEL": "Features", "LABEL": "Features",
"DISPLAY_FILE_PICKER": "Display file picker on the widget", "DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget" "DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget"
}, },
"SETTINGS_POPUP": { "SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script", "MESSENGER_HEADING": "Messenger Script",
@ -469,6 +470,7 @@
"IMAP": { "IMAP": {
"TITLE": "IMAP", "TITLE": "IMAP",
"SUBTITLE": "Set your IMAP details", "SUBTITLE": "Set your IMAP details",
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
"UPDATE": "Update IMAP settings", "UPDATE": "Update IMAP settings",
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox", "TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email", "TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
@ -484,9 +486,9 @@
"LABEL": "Port", "LABEL": "Port",
"PLACE_HOLDER": "Port" "PLACE_HOLDER": "Port"
}, },
"EMAIL": { "LOGIN": {
"LABEL": "Email", "LABEL": "Login",
"PLACE_HOLDER": "Email" "PLACE_HOLDER": "Login"
}, },
"PASSWORD": { "PASSWORD": {
"LABEL": "Password", "LABEL": "Password",
@ -512,9 +514,9 @@
"LABEL": "Port", "LABEL": "Port",
"PLACE_HOLDER": "Port" "PLACE_HOLDER": "Port"
}, },
"EMAIL": { "LOGIN": {
"LABEL": "Email", "LABEL": "Login",
"PLACE_HOLDER": "Email" "PLACE_HOLDER": "Login"
}, },
"PASSWORD": { "PASSWORD": {
"LABEL": "Password", "LABEL": "Password",
@ -527,7 +529,9 @@
"ENCRYPTION": "Encryption", "ENCRYPTION": "Encryption",
"SSL_TLS": "SSL/TLS", "SSL_TLS": "SSL/TLS",
"START_TLS": "STARTTLS", "START_TLS": "STARTTLS",
"OPEN_SSL_VERIFY_MODE": "Open SSL Verify Mode" "OPEN_SSL_VERIFY_MODE": "Open SSL Verify Mode",
} "AUTH_MECHANISM": "Authentication"
},
"NOTE": "Note: "
} }
} }

View file

@ -18,12 +18,16 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -76,7 +80,8 @@
{ "id": 2, "groupBy": "Week" }, { "id": 2, "groupBy": "Week" },
{ "id": 3, "groupBy": "Month" }, { "id": 3, "groupBy": "Month" },
{ "id": 4, "groupBy": "Year" } { "id": 4, "groupBy": "Year" }
] ],
"BUSINESS_HOURS": "Business Hours"
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
"HEADER": "Agents Overview", "HEADER": "Agents Overview",
@ -98,12 +103,16 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -161,12 +170,16 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -224,12 +237,16 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -287,12 +304,16 @@
"DESC": "( Total )" "DESC": "( Total )"
}, },
"FIRST_RESPONSE_TIME": { "FIRST_RESPONSE_TIME": {
"NAME": "First response time", "NAME": "First Response Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_TIME": { "RESOLUTION_TIME": {
"NAME": "Resolution Time", "NAME": "Resolution Time",
"DESC": "( Avg )" "DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
}, },
"RESOLUTION_COUNT": { "RESOLUTION_COUNT": {
"NAME": "Resolution Count", "NAME": "Resolution Count",
@ -361,4 +382,4 @@
} }
} }
} }
} }

View file

@ -15,6 +15,9 @@
"SUCCESS_MESSAGE": "Successfully changed the password", "SUCCESS_MESSAGE": "Successfully changed the password",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later" "ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}, },
"CAPTCHA": {
"ERROR": "Verification expired. Please solve captcha again."
},
"SUBMIT": "Submit" "SUBMIT": "Submit"
} }
} }

View file

@ -21,7 +21,8 @@
"PASSWORD": { "PASSWORD": {
"LABEL": "Password", "LABEL": "Password",
"PLACEHOLDER": "Password", "PLACEHOLDER": "Password",
"ERROR": "Password is too short" "ERROR": "Password is too short",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character"
}, },
"CONFIRM_PASSWORD": { "CONFIRM_PASSWORD": {
"LABEL": "Confirm Password", "LABEL": "Confirm Password",

View file

@ -83,7 +83,7 @@
"SELECT_ALL": "select all agents", "SELECT_ALL": "select all agents",
"SELECTED_COUNT": "%{selected} out of %{total} agents selected.", "SELECTED_COUNT": "%{selected} out of %{total} agents selected.",
"BUTTON_TEXT": "Add agents", "BUTTON_TEXT": "Add agents",
"AGENT_VALIDATION_ERROR": "Select atleaset one agent." "AGENT_VALIDATION_ERROR": "Select at least one agent."
}, },
"FINISH": { "FINISH": {

View file

@ -5,6 +5,7 @@ export default {
computed: { computed: {
...mapGetters({ ...mapGetters({
accountSummary: 'getAccountSummary', accountSummary: 'getAccountSummary',
accountReport: 'getAccountReports',
}), }),
calculateTrend() { calculateTrend() {
return metric_key => { return metric_key => {
@ -19,15 +20,32 @@ export default {
}, },
displayMetric() { displayMetric() {
return metric_key => { return metric_key => {
if ( if (this.isAverageMetricType(metric_key)) {
['avg_first_response_time', 'avg_resolution_time'].includes(
metric_key
)
) {
return formatTime(this.accountSummary[metric_key]); return formatTime(this.accountSummary[metric_key]);
} }
return this.accountSummary[metric_key]; return this.accountSummary[metric_key];
}; };
}, },
displayInfoText() {
return metric_key => {
if (this.metrics[this.currentSelection].KEY !== metric_key) {
return '';
}
if (this.isAverageMetricType(metric_key)) {
const total = this.accountReport.data
.map(item => item.count)
.reduce((prev, curr) => prev + curr, 0);
return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`;
}
return '';
};
},
isAverageMetricType() {
return metric_key => {
return ['avg_first_response_time', 'avg_resolution_time'].includes(
metric_key
);
};
},
}, },
}; };

View file

@ -11,6 +11,7 @@ describe('reportMixin', () => {
beforeEach(() => { beforeEach(() => {
getters = { getters = {
getAccountSummary: () => reportFixtures.summary, getAccountSummary: () => reportFixtures.summary,
getAccountReports: () => reportFixtures.report,
}; };
store = new Vuex.Store({ getters }); store = new Vuex.Store({ getters });
}); });
@ -24,7 +25,7 @@ describe('reportMixin', () => {
const wrapper = shallowMount(Component, { store, localVue }); const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5); expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5);
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual( expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
'3 Min' '3 Min 18 Sec'
); );
}); });
@ -38,4 +39,67 @@ describe('reportMixin', () => {
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25); expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25);
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0); expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
}); });
it('display info text', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
data() {
return {
currentSelection: 0,
};
},
computed: {
metrics() {
return [
{
DESC: '( Avg )',
INFO_TEXT: 'Total number of conversations used for computation:',
KEY: 'avg_first_response_time',
NAME: 'First Response Time',
},
];
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayInfoText('avg_first_response_time')).toEqual(
'Total number of conversations used for computation: 4'
);
});
it('do not display info text', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
data() {
return {
currentSelection: 0,
};
},
computed: {
metrics() {
return [
{
DESC: '( Total )',
INFO_TEXT: '',
KEY: 'conversation_count',
NAME: 'Conversations',
},
{
DESC: '( Avg )',
INFO_TEXT: 'Total number of conversations used for computation:',
KEY: 'avg_first_response_time',
NAME: 'First Response Time',
},
];
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayInfoText('conversation_count')).toEqual('');
expect(wrapper.vm.displayInfoText('incoming_messages_count')).toEqual('');
});
}); });

View file

@ -15,4 +15,15 @@ export default {
}, },
resolutions_count: 3, resolutions_count: 3,
}, },
report: {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
{ value: '0.00', timestamp: 1647628200, count: 0 },
{ value: '0.00', timestamp: 1647714600, count: 0 },
{ value: '0.00', timestamp: 1647801000, count: 0 },
{ value: '0.01', timestamp: 1647887400, count: 4 },
{ value: '0.00', timestamp: 1647973800, count: 0 },
{ value: '0.00', timestamp: 1648060200, count: 0 },
],
},
}; };

View file

@ -21,9 +21,19 @@
size="tiny" size="tiny"
icon="delete" icon="delete"
color-scheme="secondary" color-scheme="secondary"
@click="onDelete" @click="toggleDeleteModal"
/> />
</div> </div>
<woot-delete-modal
v-if="showDeleteModal"
:show.sync="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_NOTE.CONFIRM.TITLE')"
:message="$t('DELETE_NOTE.CONFIRM.MESSAGE')"
:confirm-text="$t('DELETE_NOTE.CONFIRM.YES')"
:reject-text="$t('DELETE_NOTE.CONFIRM.NO')"
/>
</div> </div>
<p class="note__content" v-html="formatMessage(note || '')" /> <p class="note__content" v-html="formatMessage(note || '')" />
</div> </div>
@ -59,7 +69,11 @@ export default {
default: 0, default: 0,
}, },
}, },
data() {
return {
showDeleteModal: false,
};
},
computed: { computed: {
readableTime() { readableTime() {
return this.dynamicTime(this.createdAt); return this.dynamicTime(this.createdAt);
@ -73,9 +87,19 @@ export default {
}, },
methods: { methods: {
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
onDelete() { onDelete() {
this.$emit('delete', this.id); this.$emit('delete', this.id);
}, },
confirmDeletion() {
this.onDelete();
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
},
}, },
}; };
</script> </script>

View file

@ -54,14 +54,9 @@
:class="{ error: $v.credentials.password.$error }" :class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')" :label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')" :placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:error=" :error="passwordErrorText"
$v.credentials.password.$error
? $t('SET_NEW_PASSWORD.PASSWORD.ERROR')
: ''
"
@blur="$v.credentials.password.$touch" @blur="$v.credentials.password.$touch"
/> />
<woot-input <woot-input
v-model.trim="credentials.confirmPassword" v-model.trim="credentials.confirmPassword"
type="password" type="password"
@ -77,9 +72,17 @@
/> />
<div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box"> <div v-if="globalConfig.hCaptchaSiteKey" class="h-captcha--box">
<vue-hcaptcha <vue-hcaptcha
ref="hCaptcha"
:class="{ error: !hasAValidCaptcha && didCaptchaReset }"
:sitekey="globalConfig.hCaptchaSiteKey" :sitekey="globalConfig.hCaptchaSiteKey"
@verify="onRecaptchaVerified" @verify="onRecaptchaVerified"
/> />
<span
v-if="!hasAValidCaptcha && didCaptchaReset"
class="captcha-error"
>
{{ $t('SET_NEW_PASSWORD.CAPTCHA.ERROR') }}
</span>
</div> </div>
<woot-submit-button <woot-submit-button
:disabled="isSignupInProgress || !hasAValidCaptcha" :disabled="isSignupInProgress || !hasAValidCaptcha"
@ -114,6 +117,7 @@ import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import { DEFAULT_REDIRECT_URL } from '../../constants'; import { DEFAULT_REDIRECT_URL } from '../../constants';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha'; import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import { isValidPassword } from 'shared/helpers/Validators';
export default { export default {
components: { components: {
VueHcaptcha, VueHcaptcha,
@ -129,6 +133,7 @@ export default {
confirmPassword: '', confirmPassword: '',
hCaptchaClientResponse: '', hCaptchaClientResponse: '',
}, },
didCaptchaReset: false,
isSignupInProgress: false, isSignupInProgress: false,
error: '', error: '',
}; };
@ -149,6 +154,7 @@ export default {
}, },
password: { password: {
required, required,
isValidPassword,
minLength: minLength(6), minLength: minLength(6),
}, },
confirmPassword: { confirmPassword: {
@ -178,11 +184,25 @@ export default {
} }
return true; return true;
}, },
passwordErrorText() {
const { password } = this.$v.credentials;
if (!password.$error) {
return '';
}
if (!password.minLength) {
return this.$t('REGISTER.PASSWORD.ERROR');
}
if (!password.isValidPassword) {
return this.$t('REGISTER.PASSWORD.IS_INVALID_PASSWORD');
}
return '';
},
}, },
methods: { methods: {
async submit() { async submit() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) { if (this.$v.$invalid) {
this.resetCaptcha();
return; return;
} }
this.isSignupInProgress = true; this.isSignupInProgress = true;
@ -194,6 +214,7 @@ export default {
} catch (error) { } catch (error) {
let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE'); let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE');
if (error.response && error.response.data.message) { if (error.response && error.response.data.message) {
this.resetCaptcha();
errorMessage = error.response.data.message; errorMessage = error.response.data.message;
} }
this.showAlert(errorMessage); this.showAlert(errorMessage);
@ -203,6 +224,15 @@ export default {
}, },
onRecaptchaVerified(token) { onRecaptchaVerified(token) {
this.credentials.hCaptchaClientResponse = token; this.credentials.hCaptchaClientResponse = token;
this.didCaptchaReset = false;
},
resetCaptcha() {
if (!this.globalConfig.hCaptchaSiteKey) {
return;
}
this.$refs.hCaptcha.reset();
this.credentials.hCaptchaClientResponse = '';
this.didCaptchaReset = true;
}, },
}, },
}; };
@ -254,6 +284,18 @@ export default {
.h-captcha--box { .h-captcha--box {
margin-bottom: var(--space-one); margin-bottom: var(--space-one);
.captcha-error {
color: var(--r-400);
font-size: var(--font-size-small);
}
&::v-deep .error {
iframe {
border: 1px solid var(--r-500);
border-radius: var(--border-radius-normal);
}
}
} }
} }
</style> </style>

View file

@ -3,6 +3,7 @@
<sidebar <sidebar
:route="currentRoute" :route="currentRoute"
:class="sidebarClassName" :class="sidebarClassName"
@open-notification-panel="openNotificationPanel"
@toggle-account-modal="toggleAccountModal" @toggle-account-modal="toggleAccountModal"
@open-key-shortcut-modal="toggleKeyShortcutModal" @open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal" @close-key-shortcut-modal="closeKeyShortcutModal"
@ -25,6 +26,10 @@
@close="closeKeyShortcutModal" @close="closeKeyShortcutModal"
@clickaway="closeKeyShortcutModal" @clickaway="closeKeyShortcutModal"
/> />
<notification-panel
v-if="isNotificationPanel"
@close="closeNotificationPanel"
/>
<woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup"> <woot-modal :show.sync="showAddLabelModal" :on-close="hideAddLabelPopup">
<add-label-modal @close="hideAddLabelPopup" /> <add-label-modal @close="hideAddLabelPopup" />
</woot-modal> </woot-modal>
@ -40,6 +45,7 @@ import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShor
import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal'; import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal';
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector'; import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector';
import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue'; import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
export default { export default {
components: { components: {
@ -49,6 +55,7 @@ export default {
AddAccountModal, AddAccountModal,
AccountSelector, AccountSelector,
AddLabelModal, AddLabelModal,
NotificationPanel,
}, },
data() { data() {
return { return {
@ -58,6 +65,7 @@ export default {
showCreateAccountModal: false, showCreateAccountModal: false,
showAddLabelModal: false, showAddLabelModal: false,
showShortcutModal: false, showShortcutModal: false,
isNotificationPanel: false,
}; };
}, },
computed: { computed: {
@ -84,7 +92,6 @@ export default {
}, },
}, },
mounted() { mounted() {
this.$store.dispatch('setCurrentAccountId', this.$route.params.accountId);
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
this.handleResize(); this.handleResize();
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar); bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
@ -126,6 +133,12 @@ export default {
hideAddLabelPopup() { hideAddLabelPopup() {
this.showAddLabelModal = false; this.showAddLabelModal = false;
}, },
openNotificationPanel() {
this.isNotificationPanel = true;
},
closeNotificationPanel() {
this.isNotificationPanel = false;
},
}, },
}; };
</script> </script>

View file

@ -59,7 +59,29 @@
</div> </div>
<div class="row"> <div class="row">
<div class="columns"> <div class="columns">
<label :class="{ error: $v.message.$error }"> <div v-if="isAnEmailInbox || isAnWebWidgetInbox">
<label>
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<reply-email-head
v-if="isAnEmailInbox"
:cc-emails.sync="ccEmails"
:bcc-emails.sync="bccEmails"
/>
<label class="editor-wrap">
<woot-message-editor
v-model="message"
class="message-editor"
:class="{ editor_warning: $v.message.$error }"
:placeholder="$t('NEW_CONVERSATION.FORM.MESSAGE.PLACEHOLDER')"
@blur="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="editor-warning__message">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.ERROR') }}
</span>
</label>
</label>
</div>
<label v-else :class="{ error: $v.message.$error }">
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }} {{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
<textarea <textarea
v-model="message" v-model="message"
@ -89,6 +111,8 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail'; import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin'; import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
@ -98,6 +122,8 @@ import { required, requiredIf } from 'vuelidate/lib/validators';
export default { export default {
components: { components: {
Thumbnail, Thumbnail,
WootMessageEditor,
ReplyEmailHead,
}, },
mixins: [alertMixin], mixins: [alertMixin],
props: { props: {
@ -116,6 +142,8 @@ export default {
subject: '', subject: '',
message: '', message: '',
selectedInbox: '', selectedInbox: '',
bccEmails: '',
ccEmails: '',
}; };
}, },
validations: { validations: {
@ -136,7 +164,7 @@ export default {
currentUser: 'getCurrentUser', currentUser: 'getCurrentUser',
}), }),
getNewConversation() { getNewConversation() {
return { const payload = {
inboxId: this.targetInbox.inbox.id, inboxId: this.targetInbox.inbox.id,
sourceId: this.targetInbox.source_id, sourceId: this.targetInbox.source_id,
contactId: this.contact.id, contactId: this.contact.id,
@ -144,6 +172,14 @@ export default {
mailSubject: this.subject, mailSubject: this.subject,
assigneeId: this.currentUser.id, assigneeId: this.currentUser.id,
}; };
if (this.ccEmails) {
payload.message.cc_emails = this.ccEmails;
}
if (this.bccEmails) {
payload.message.bcc_emails = this.bccEmails;
}
return payload;
}, },
targetInbox: { targetInbox: {
get() { get() {
@ -168,6 +204,12 @@ export default {
this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL this.selectedInbox.inbox.channel_type === INBOX_TYPES.EMAIL
); );
}, },
isAnWebWidgetInbox() {
return (
this.selectedInbox &&
this.selectedInbox.inbox.channel_type === INBOX_TYPES.WEB
);
},
}, },
methods: { methods: {
onCancel() { onCancel() {

View file

@ -0,0 +1,274 @@
<template>
<div class="modal-mask">
<div
v-on-clickaway="closeNotificationPanel"
class="notification-wrap flex-space-between"
>
<div class="header-wrap w-full flex-space-between">
<div class="header-title--wrap flex-view">
<span class="header-title">
{{ $t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.TITLE') }}
</span>
<span v-if="totalUnreadNotifications" class="total-count block-title">
{{ totalUnreadNotifications }}
</span>
</div>
<div class="flex-view">
<woot-button
v-if="!noUnreadNotificationAvailable"
color-scheme="primary"
variant="smooth"
size="tiny"
class-names="action-button"
:is-loading="uiFlags.isUpdating"
@click="onMarkAllDoneClick"
>
{{ $t('NOTIFICATIONS_PAGE.MARK_ALL_DONE') }}
</woot-button>
<woot-button
color-scheme="secondary"
variant="link"
size="tiny"
icon="dismiss"
@click="closeNotificationPanel"
/>
</div>
</div>
<notification-panel-list
:notifications="getUnreadNotifications"
:is-loading="uiFlags.isFetching"
:on-click-notification="openConversation"
:in-last-page="inLastPage"
/>
<div v-if="records.length !== 0" class="footer-wrap flex-space-between">
<div class="flex-view">
<woot-button
size="medium"
variant="clear"
color-scheme="secondary"
class-names="page-change--button"
:is-disabled="inFirstPage"
@click="onClickFirstPage"
>
<fluent-icon icon="chevron-left" size="16" />
<fluent-icon
icon="chevron-left"
size="16"
class="margin-left-minus-slab"
/>
</woot-button>
<woot-button
color-scheme="secondary"
variant="clear"
size="medium"
icon="chevron-left"
:disabled="inFirstPage"
@click="onClickPreviousPage"
>
</woot-button>
</div>
<span class="page-count"> {{ currentPage }} - {{ lastPage }} </span>
<div class="flex-view">
<woot-button
color-scheme="secondary"
variant="clear"
size="medium"
icon="chevron-right"
:disabled="inLastPage"
@click="onClickNextPage"
>
</woot-button>
<woot-button
size="medium"
variant="clear"
color-scheme="secondary"
class-names="page-change--button"
:disabled="inLastPage"
@click="onClickLastPage"
>
<fluent-icon icon="chevron-right" size="16" />
<fluent-icon
icon="chevron-right"
size="16"
class="margin-left-minus-slab"
/>
</woot-button>
</div>
</div>
<div v-else></div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import NotificationPanelList from './NotificationPanelList';
export default {
components: {
NotificationPanelList,
},
mixins: [clickaway],
data() {
return {
pageSize: 15,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
meta: 'notifications/getMeta',
records: 'notifications/getNotifications',
uiFlags: 'notifications/getUIFlags',
}),
totalUnreadNotifications() {
return this.meta.unreadCount;
},
noUnreadNotificationAvailable() {
return this.meta.unreadCount === 0;
},
getUnreadNotifications() {
return this.records.filter(notification => notification.read_at === null);
},
currentPage() {
return Number(this.meta.currentPage);
},
lastPage() {
if (this.totalUnreadNotifications > 15) {
return Math.ceil(this.totalUnreadNotifications / this.pageSize);
}
return 1;
},
inFirstPage() {
const page = Number(this.meta.currentPage);
return page === 1;
},
inLastPage() {
return this.currentPage === this.lastPage;
},
},
mounted() {
this.$store.dispatch('notifications/get', { page: 1 });
},
methods: {
onPageChange(page) {
this.$store.dispatch('notifications/get', { page });
},
openConversation(notification) {
const {
primary_actor_id: primaryActorId,
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId },
} = notification;
this.$store.dispatch('notifications/read', {
primaryActorId,
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push({
name: 'inbox_conversation',
params: { conversation_id: conversationId },
});
this.$emit('close');
},
onClickNextPage() {
if (!this.inLastPage) {
const page = this.currentPage + 1;
this.onPageChange(page);
}
},
onClickPreviousPage() {
if (!this.inFirstPage) {
const page = this.currentPage - 1;
this.onPageChange(page);
}
},
onClickFirstPage() {
if (!this.inFirstPage) {
const page = 1;
this.onPageChange(page);
}
},
onClickLastPage() {
if (!this.inLastPage) {
const page = this.lastPage;
this.onPageChange(page);
}
},
onMarkAllDoneClick() {
this.$store.dispatch('notifications/readAll');
},
closeNotificationPanel() {
this.$emit('close');
},
},
};
</script>
<style lang="scss" scoped>
.flex-view {
display: flex;
}
.flex-space-between {
display: flex;
justify-content: space-between;
}
.notification-wrap {
flex-direction: column;
height: 90vh;
width: 52rem;
background-color: var(--white);
border-radius: var(--border-radius-medium);
position: absolute;
left: var(--space-jumbo);
margin: var(--space-small);
}
.header-wrap {
flex-direction: row;
align-items: center;
border-bottom: 1px solid var(--s-50);
padding: var(--space-two) var(--space-medium) var(--space-slab)
var(--space-medium);
.header-title--wrap {
align-items: center;
}
.header-title {
font-size: var(--font-size-two);
font-weight: var(--font-weight-black);
}
.total-count {
padding: var(--space-smaller) var(--space-small);
background: var(--b-50);
border-radius: var(--border-radius-rounded);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
}
.action-button {
padding: var(--space-micro) var(--space-small);
margin-right: var(--space-small);
}
}
.page-count {
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
color: var(--s-500);
}
.footer-wrap {
align-items: center;
padding: var(--space-smaller) var(--space-two);
}
.page-change--button:hover {
background: var(--s-50);
}
</style>

View file

@ -0,0 +1,219 @@
<template>
<div class="notification-list-item--wrap h-full flex-view ">
<woot-button
v-for="notificationItem in notifications"
v-show="!isLoading"
:key="notificationItem.id"
size="expanded"
color-scheme="secondary"
variant="link"
@click="() => onClickNotification(notificationItem)"
>
<div class="notification-list--wrap flex-view w-full">
<div
v-if="!notificationItem.read_at"
class="notification-unread--indicator"
></div>
<div v-else class="empty flex-view"></div>
<div class="notification-content--wrap w-full flex-space-between">
<div class="flex-space-between">
<div class="title-wrap flex-view ">
<span class="notification-title">
{{
`#${
notificationItem.primary_actor
? notificationItem.primary_actor.id
: $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}`
}}
</span>
<span class="notification-type">
{{
$t(
`NOTIFICATIONS_PAGE.TYPE_LABEL.${notificationItem.notification_type}`
)
}}
</span>
</div>
<div>
<thumbnail
v-if="notificationItem.primary_actor.meta.assignee"
:src="notificationItem.primary_actor.meta.assignee.thumbnail"
size="16px"
:username="notificationItem.primary_actor.meta.assignee.name"
/>
</div>
</div>
<div class="w-full flex-view ">
<span class="notification-message text-truncate">
{{ notificationItem.push_message_title }}
</span>
</div>
<span class="timestamp flex-view">
{{ dynamicTime(notificationItem.created_at) }}
</span>
</div>
</div>
</woot-button>
<empty-state
v-if="showEmptyResult"
:title="$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.EMPTY_MESSAGE')"
/>
<woot-button
v-if="!isLoading && inLastPage"
size="medium"
variant="clear"
color-scheme="primary"
class-names="action-button"
@click="openNotificationPage"
>
{{ $t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.ALL_NOTIFICATIONS') }}
</woot-button>
<div v-if="isLoading" class="notifications-loader flex-view">
<spinner />
<span>{{
$t('NOTIFICATIONS_PAGE.UNREAD_NOTIFICATION.LOADING_UNREAD_MESSAGE')
}}</span>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import timeMixin from '../../../../mixins/time';
export default {
components: {
Thumbnail,
Spinner,
EmptyState,
},
mixins: [timeMixin],
props: {
notifications: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: true,
},
onClickNotification: {
type: Function,
default: () => {},
},
inLastPage: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
}),
showEmptyResult() {
return !this.isLoading && this.notifications.length === 0;
},
},
methods: {
openNotificationPage() {
if (this.$route.name !== 'notifications_index') {
this.$router.push({
name: 'notifications_index',
});
}
},
},
};
</script>
<style lang="scss" scoped>
.flex-view {
display: flex;
}
.flex-space-between {
display: flex;
justify-content: space-between;
}
.notification-list-item--wrap {
flex-direction: column;
padding: var(--space-small) var(--space-slab);
overflow: scroll;
}
.empty {
width: var(--space-small);
}
.notification-list--wrap {
flex-direction: row;
align-items: center;
padding: var(--space-slab);
line-height: 1.4;
border-bottom: 1px solid var(--b-50);
}
.notification-list--wrap:hover {
background: var(--b-100);
border-radius: var(--border-radius-normal);
}
.notification-content--wrap {
flex-direction: column;
margin-left: var(--space-slab);
overflow: hidden;
}
.title-wrap {
align-items: center;
}
.notification-title {
font-weight: var(--font-weight-black);
}
.notification-type {
font-size: var(--font-size-micro);
padding: var(--space-micro) var(--space-smaller);
margin-left: var(--space-small);
background: var(--s-50);
border-radius: var(--border-radius-normal);
}
.notification-message {
color: var(--color-body);
font-weight: var(--font-weight-normal);
}
.timestamp {
margin-top: var(--space-smaller);
color: var(--b-500);
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
}
.notification-unread--indicator {
width: var(--space-small);
height: var(--space-small);
border-radius: var(--border-radius-rounded);
background: var(--color-woot);
}
.action-button {
margin-top: var(--space-slab);
}
.notifications-loader {
align-items: center;
justify-content: center;
margin: var(--space-larger) var(--space-small);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
}
</style>

View file

@ -17,17 +17,17 @@
@click="() => onClickNotification(notificationItem)" @click="() => onClickNotification(notificationItem)"
> >
<td> <td>
<div class=""> <div class="flex-view notification-contant--wrap">
<h5 class="notification--title"> <h5 class="notification--title">
{{ {{
`#${ `#${
notificationItem.primary_actor notificationItem.primary_actor
? notificationItem.primary_actor.id ? notificationItem.primary_actor.id
: 'deleted' : $t(`NOTIFICATIONS_PAGE.DELETE_TITLE`)
}` }`
}} }}
</h5> </h5>
<span class="notification--message-title"> <span class="notification--message-title text-truncate">
{{ notificationItem.push_message_title }} {{ notificationItem.push_message_title }}
</span> </span>
</div> </div>
@ -197,6 +197,11 @@ export default {
text-align: right; text-align: right;
} }
.notification-contant--wrap {
flex-direction: column;
max-width: 50rem;
}
.notification--message-title { .notification--message-title {
color: var(--s-700); color: var(--s-700);
} }

View file

@ -35,7 +35,9 @@
</td> </td>
<!-- Agent Name + Email --> <!-- Agent Name + Email -->
<td> <td>
<span class="agent-name">{{ agent.name }}</span> <span class="agent-name">
{{ agent.name }}
</span>
<span>{{ agent.email }}</span> <span>{{ agent.email }}</span>
</td> </td>
<!-- Agent Role + Verification Status --> <!-- Agent Role + Verification Status -->

View file

@ -100,7 +100,11 @@
:dropdown-values=" :dropdown-values="
getActionDropdownValues(automation.actions[i].action_name) getActionDropdownValues(automation.actions[i].action_name)
" "
:show-action-input="
showActionInput(automation.actions[i].action_name)
"
:v="$v.automation.actions.$each[i]" :v="$v.automation.actions.$each[i]"
@resetAction="resetAction(i)"
@removeAction="removeAction(i)" @removeAction="removeAction(i)"
/> />
<div class="filter-actions"> <div class="filter-actions">
@ -187,7 +191,14 @@ export default {
required, required,
$each: { $each: {
action_params: { action_params: {
required, required: requiredIf(prop => {
if (prop.action_name === 'send_email_to_team') return true;
return !(
prop.action_name === 'mute_conversation' ||
prop.action_name === 'snooze_conversation' ||
prop.action_name === 'resolve_conversation'
);
}),
}, },
}, },
}, },
@ -407,14 +418,15 @@ export default {
submitAutomation() { submitAutomation() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) return; if (this.$v.$invalid) return;
this.automation.conditions[ const automation = JSON.parse(JSON.stringify(this.automation));
this.automation.conditions.length - 1 automation.conditions[
automation.conditions.length - 1
].query_operator = null; ].query_operator = null;
this.automation.conditions = filterQueryGenerator( automation.conditions = filterQueryGenerator(
this.automation.conditions automation.conditions
).payload; ).payload;
this.automation.actions = actionQueryGenerator(this.automation.actions); automation.actions = actionQueryGenerator(automation.actions);
this.$emit('saveAutomation', this.automation); this.$emit('saveAutomation', automation);
}, },
resetFilter(index, currentCondition) { resetFilter(index, currentCondition) {
this.automation.conditions[index].filter_operator = this.automationTypes[ this.automation.conditions[index].filter_operator = this.automationTypes[
@ -424,11 +436,23 @@ export default {
).filterOperators[0].value; ).filterOperators[0].value;
this.automation.conditions[index].values = ''; this.automation.conditions[index].values = '';
}, },
resetAction(index) {
this.automation.actions[index].action_params = [];
},
showUserInput(operatorType) { showUserInput(operatorType) {
if (operatorType === 'is_present' || operatorType === 'is_not_present') if (operatorType === 'is_present' || operatorType === 'is_not_present')
return false; return false;
return true; return true;
}, },
showActionInput(actionName) {
if (actionName === 'send_email_to_team' || actionName === 'send_message')
return false;
const type = AUTOMATION_ACTION_TYPES.find(
action => action.key === actionName
).inputType;
if (type === null) return false;
return true;
},
}, },
}; };
</script> </script>

View file

@ -97,6 +97,9 @@
:dropdown-values=" :dropdown-values="
getActionDropdownValues(automation.actions[i].action_name) getActionDropdownValues(automation.actions[i].action_name)
" "
:show-action-input="
showActionInput(automation.actions[i].action_name)
"
:v="$v.automation.actions.$each[i]" :v="$v.automation.actions.$each[i]"
@removeAction="removeAction(i)" @removeAction="removeAction(i)"
/> />
@ -192,7 +195,13 @@ export default {
required, required,
$each: { $each: {
action_params: { action_params: {
required, required: requiredIf(prop => {
return !(
prop.action_name === 'mute_conversation' ||
prop.action_name === 'snooze_conversation' ||
prop.action_name === 'resolve_conversation'
);
}),
}, },
}, },
}, },
@ -246,7 +255,7 @@ export default {
}, },
}, },
mounted() { mounted() {
this.formatConditions(this.selectedResponse); this.formatAutomation(this.selectedResponse);
}, },
methods: { methods: {
onEventChange() { onEventChange() {
@ -415,14 +424,15 @@ export default {
submitAutomation() { submitAutomation() {
this.$v.$touch(); this.$v.$touch();
if (this.$v.$invalid) return; if (this.$v.$invalid) return;
this.automation.conditions[ const automation = JSON.parse(JSON.stringify(this.automation));
this.automation.conditions.length - 1 automation.conditions[
automation.conditions.length - 1
].query_operator = null; ].query_operator = null;
this.automation.conditions = filterQueryGenerator( automation.conditions = filterQueryGenerator(
this.automation.conditions automation.conditions
).payload; ).payload;
this.automation.actions = actionQueryGenerator(this.automation.actions); automation.actions = actionQueryGenerator(automation.actions);
this.$emit('saveAutomation', this.automation, 'EDIT'); this.$emit('saveAutomation', automation, 'EDIT');
}, },
resetFilter(index, currentCondition) { resetFilter(index, currentCondition) {
this.automation.conditions[index].filter_operator = this.automationTypes[ this.automation.conditions[index].filter_operator = this.automationTypes[
@ -437,7 +447,7 @@ export default {
return false; return false;
return true; return true;
}, },
formatConditions(automation) { formatAutomation(automation) {
const formattedConditions = automation.conditions.map(condition => { const formattedConditions = automation.conditions.map(condition => {
const inputType = this.automationTypes[ const inputType = this.automationTypes[
automation.event_name automation.event_name
@ -457,11 +467,29 @@ export default {
}; };
}); });
const formattedActions = automation.actions.map(action => { const formattedActions = automation.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
const inputType = AUTOMATION_ACTION_TYPES.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select') {
actionParams = [
...this.getActionDropdownValues(action.action_name),
].filter(item => [...action.action_params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getActionDropdownValues(action.action_name),
].filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
} else actionParams = [...action.action_params];
}
return { return {
...action, ...action,
action_params: [ action_params: actionParams,
...this.getActionDropdownValues(action.action_name),
].filter(item => [...action.action_params].includes(item.id)),
}; };
}); });
this.automation = { this.automation = {
@ -470,6 +498,15 @@ export default {
actions: formattedActions, actions: formattedActions,
}; };
}, },
showActionInput(actionName) {
if (actionName === 'send_email_to_team' || actionName === 'send_message')
return false;
const type = AUTOMATION_ACTION_TYPES.find(
action => action.key === actionName
).inputType;
if (type === null) return false;
return true;
},
}, },
}; };
</script> </script>

View file

@ -34,19 +34,10 @@
<td>{{ automation.name }}</td> <td>{{ automation.name }}</td>
<td>{{ automation.description }}</td> <td>{{ automation.description }}</td>
<td> <td>
<button <woot-switch
type="button" :value="automation.active"
class="toggle-button" @input="toggleAutomation(automation, automation.active)"
:class="{ active: automation.active }" />
role="switch"
:aria-checked="automation.active.toString()"
@click="toggleAutomation(automation, automation.active)"
>
<span
aria-hidden="true"
:class="{ active: automation.active }"
></span>
</button>
</td> </td>
<td>{{ readableTime(automation.created_on) }}</td> <td>{{ readableTime(automation.created_on) }}</td>
<td class="button-wrapper"> <td class="button-wrapper">
@ -238,7 +229,6 @@ export default {
mode === 'EDIT' mode === 'EDIT'
? this.$t('AUTOMATION.EDIT.API.SUCCESS_MESSAGE') ? this.$t('AUTOMATION.EDIT.API.SUCCESS_MESSAGE')
: this.$t('AUTOMATION.ADD.API.SUCCESS_MESSAGE'); : this.$t('AUTOMATION.ADD.API.SUCCESS_MESSAGE');
await await this.$store.dispatch(action, payload); await await this.$store.dispatch(action, payload);
this.showAlert(this.$t(successMessage)); this.showAlert(this.$t(successMessage));
this.hideAddPopup(); this.hideAddPopup();
@ -263,7 +253,7 @@ export default {
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION', { : this.$t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION', {
automationName: automation.name, automationName: automation.name,
}); });
// Check if uses confirms to proceed // Check if user confirms to proceed
const ok = await this.$refs.confirmDialog.showConfirmation(); const ok = await this.$refs.confirmDialog.showConfirmation();
if (ok) { if (ok) {
await await this.$store.dispatch('automations/update', { await await this.$store.dispatch('automations/update', {
@ -290,41 +280,4 @@ export default {
.automation__status-checkbox { .automation__status-checkbox {
margin: 0; margin: 0;
} }
.toggle-button {
background-color: var(--s-200);
position: relative;
display: inline-flex;
height: 19px;
width: 34px;
border: 2px solid transparent;
border-radius: var(--border-radius-large);
cursor: pointer;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
flex-shrink: 0;
}
.toggle-button.active {
background-color: var(--w-500);
}
.toggle-button span {
--space-one-point-five: 1.5rem;
height: var(--space-one-point-five);
width: var(--space-one-point-five);
display: inline-block;
background-color: var(--white);
box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
transform: translate(0, 0);
border-radius: 100%;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.toggle-button span.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
</style> </style>

View file

@ -76,10 +76,46 @@ export const AUTOMATIONS = {
name: 'Add a label', name: 'Add a label',
attributeI18nKey: 'ADD_LABEL', attributeI18nKey: 'ADD_LABEL',
}, },
{
key: 'send_email_to_team',
name: 'Send an email to team',
attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'Send a message',
attributeI18nKey: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'Send an email transcript',
attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
name: 'Mute conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
name: 'Snooze conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'Resolve conversation',
attributeI18nKey: 'RESOLVE_CONVERSATION',
},
{
key: 'send_webhook_event',
name: 'Send Webhook Event',
attributeI18nKey: 'SEND_WEBHOOK_EVENT',
},
// { // {
// key: 'send_email_to_team', // key: 'send_attachment',
// name: 'Send an email to team', // name: 'Send Attachment',
// attributeI18nKey: 'SEND_EMAIL_TO_TEAM', // attributeI18nKey: 'SEND_ATTACHMENT',
// }, // },
], ],
}, },
@ -107,7 +143,7 @@ export const AUTOMATIONS = {
filterOperators: OPERATOR_TYPES_1, filterOperators: OPERATOR_TYPES_1,
}, },
{ {
key: 'referrer', key: 'referer',
name: 'Referrer Link', name: 'Referrer Link',
attributeI18nKey: 'REFERER_LINK', attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text', inputType: 'plain_text',
@ -120,16 +156,51 @@ export const AUTOMATIONS = {
name: 'Assign a team', name: 'Assign a team',
attributeI18nKey: 'ASSIGN_TEAM', attributeI18nKey: 'ASSIGN_TEAM',
}, },
// {
// key: 'send_email_to_team',
// name: 'Send an email to team',
// attributeI18nKey: 'SEND_MESSAGE',
// },
{ {
key: 'assign_agent', key: 'assign_agent',
name: 'Assign an agent', name: 'Assign an agent',
attributeI18nKey: 'ASSIGN_AGENT', attributeI18nKey: 'ASSIGN_AGENT',
}, },
{
key: 'send_email_to_team',
name: 'Send an email to team',
attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'Send a message',
attributeI18nKey: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'Send an email transcript',
attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
name: 'Mute conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
name: 'Snooze conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'Resolve conversation',
attributeI18nKey: 'RESOLVE_CONVERSATION',
},
{
key: 'send_webhook_event',
name: 'Send Webhook Event',
attributeI18nKey: 'SEND_WEBHOOK_EVENT',
},
// {
// key: 'send_attachment',
// name: 'Send Attachment',
// attributeI18nKey: 'SEND_ATTACHMENT',
// },
], ],
}, },
conversation_updated: { conversation_updated: {
@ -183,17 +254,51 @@ export const AUTOMATIONS = {
name: 'Assign a team', name: 'Assign a team',
attributeI18nKey: 'ASSIGN_TEAM', attributeI18nKey: 'ASSIGN_TEAM',
}, },
// {
// key: 'send_email_to_team',
// name: 'Send an email to team',
// attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
// },
{ {
key: 'assign_agent', key: 'assign_agent',
name: 'Assign an agent', name: 'Assign an agent',
attributeI18nKey: 'ASSIGN_AGENT', attributeI18nKey: 'ASSIGN_AGENT',
attributeKey: 'assignee_id',
}, },
{
key: 'send_email_to_team',
name: 'Send an email to team',
attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'Send a message',
attributeI18nKey: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'Send an email transcript',
attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
name: 'Mute conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
name: 'Snooze conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'resolve_conversation',
name: 'Resolve conversation',
attributeI18nKey: 'RESOLVE_CONVERSATION',
},
{
key: 'send_webhook_event',
name: 'Send Webhook Event',
attributeI18nKey: 'SEND_WEBHOOK_EVENT',
},
// {
// key: 'send_attachment',
// name: 'Send Attachment',
// attributeI18nKey: 'SEND_ATTACHMENT',
// },
], ],
}, },
}; };
@ -217,13 +322,51 @@ export const AUTOMATION_ACTION_TYPES = [
{ {
key: 'assign_team', key: 'assign_team',
label: 'Assign a team', label: 'Assign a team',
inputType: 'multi_select',
}, },
{ {
key: 'add_label', key: 'add_label',
label: 'Add a label', label: 'Add a label',
inputType: 'multi_select',
},
{
key: 'send_email_to_team',
label: 'Send an email to team',
inputType: 'team_message',
},
{
key: 'send_email_transcript',
label: 'Send an email transcript',
inputType: 'email',
},
{
key: 'mute_conversation',
label: 'Mute conversation',
inputType: null,
},
{
key: 'snooze_conversation',
label: 'Snooze conversation',
inputType: null,
},
{
key: 'resolve_conversation',
label: 'Resolve conversation',
inputType: null,
},
{
key: 'send_webhook_event',
label: 'Send Webhook Event',
inputType: 'url',
}, },
// { // {
// key: 'send_email_to_team', // key: 'send_attachment',
// label: 'Send an email to team', // label: 'Send Attachment',
// inputType: 'file',
// }, // },
{
key: 'send_message',
label: 'Send a message',
inputType: 'textarea',
},
]; ];

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