Merge branch 'release/1.17.0'
This commit is contained in:
@@ -116,6 +116,10 @@ SLACK_CLIENT_SECRET=
|
||||
### Change this env variable only if you are using a custom build mobile app
|
||||
## Mobile app env variables
|
||||
IOS_APP_ID=6C953F3RX2.com.chatwoot.app
|
||||
ANDROID_BUNDLE_ID=com.chatwoot.app
|
||||
|
||||
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
|
||||
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
|
||||
|
||||
|
||||
### Smart App Banner
|
||||
@@ -143,6 +147,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||
# maxmindb api key to use geoip2 service
|
||||
# IP_LOOKUP_API_KEY=
|
||||
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
# CW_API_ONLY_SERVER=false
|
||||
|
||||
## Development Only Config
|
||||
# if you want to use letter_opener for local emails
|
||||
# LETTER_OPENER=true
|
||||
|
||||
@@ -44,6 +44,9 @@ Metrics/BlockLength:
|
||||
- '**/routes.rb'
|
||||
- 'config/environments/*'
|
||||
- db/schema.rb
|
||||
Metrics/ModuleLength:
|
||||
Exclude:
|
||||
- lib/woot_message_seeder.rb
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
@@ -51,6 +54,7 @@ Rails/ApplicationController:
|
||||
- 'app/controllers/widget_tests_controller.rb'
|
||||
- 'app/controllers/widgets_controller.rb'
|
||||
- 'app/controllers/platform_controller.rb'
|
||||
- 'app/controllers/public_controller.rb'
|
||||
Style/ClassAndModuleChildren:
|
||||
EnforcedStyle: compact
|
||||
Exclude:
|
||||
|
||||
@@ -2,6 +2,7 @@ import { addDecorator } from '@storybook/vue';
|
||||
import Vue from 'vue';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import Vuelidate from 'vuelidate';
|
||||
|
||||
import WootUiKit from '../app/javascript/dashboard/components';
|
||||
import i18n from '../app/javascript/dashboard/i18n';
|
||||
@@ -9,6 +10,7 @@ import i18n from '../app/javascript/dashboard/i18n';
|
||||
import '../app/javascript/dashboard/assets/scss/storybook.scss';
|
||||
|
||||
Vue.use(VueI18n);
|
||||
Vue.use(Vuelidate);
|
||||
Vue.use(WootUiKit);
|
||||
Vue.use(Vuex);
|
||||
|
||||
|
||||
5
Gemfile
5
Gemfile
@@ -59,6 +59,7 @@ gem 'barnes'
|
||||
|
||||
##--- gems for authentication & authorization ---##
|
||||
gem 'devise'
|
||||
gem 'devise-secure_password', '~> 2.0'
|
||||
gem 'devise_token_auth'
|
||||
# authorization
|
||||
gem 'jwt'
|
||||
@@ -72,7 +73,7 @@ gem 'wisper', '2.0.0'
|
||||
|
||||
##--- gems for channels ---##
|
||||
# TODO: bump up gem to 2.0
|
||||
gem 'facebook-messenger', '1.5.0'
|
||||
gem 'facebook-messenger'
|
||||
gem 'telegram-bot-ruby'
|
||||
gem 'twilio-ruby', '~> 5.32.0'
|
||||
# twitty will handle subscription of twitter account events
|
||||
@@ -132,8 +133,6 @@ group :test do
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
# locking until https://github.com/codeclimate/test-reporter/issues/418 is resolved
|
||||
gem 'action-cable-testing'
|
||||
gem 'bundle-audit', require: false
|
||||
gem 'byebug', platform: :mri
|
||||
gem 'factory_bot_rails'
|
||||
|
||||
27
Gemfile.lock
27
Gemfile.lock
@@ -16,8 +16,6 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action-cable-testing (0.6.1)
|
||||
actioncable (>= 5.0)
|
||||
actioncable (6.0.3.7)
|
||||
actionpack (= 6.0.3.7)
|
||||
nio4r (~> 2.0)
|
||||
@@ -125,7 +123,7 @@ GEM
|
||||
barnes (0.0.8)
|
||||
multi_json (~> 1)
|
||||
statsd-ruby (~> 1.1)
|
||||
bcrypt (3.1.15)
|
||||
bcrypt (3.1.16)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.4.8)
|
||||
msgpack (~> 1.0)
|
||||
@@ -160,12 +158,15 @@ GEM
|
||||
declarative-option (0.1.0)
|
||||
descendants_tracker (0.0.4)
|
||||
thread_safe (~> 0.3, >= 0.3.1)
|
||||
devise (4.7.2)
|
||||
devise (4.8.0)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-secure_password (2.0.1)
|
||||
devise (>= 4.0.0, < 5.0.0)
|
||||
railties (>= 5.0.0, < 7.0.0)
|
||||
devise_token_auth (1.1.4)
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
@@ -188,7 +189,7 @@ GEM
|
||||
et-orbi (1.2.4)
|
||||
tzinfo
|
||||
execjs (2.7.0)
|
||||
facebook-messenger (1.5.0)
|
||||
facebook-messenger (2.0.1)
|
||||
httparty (~> 0.13, >= 0.13.7)
|
||||
rack (>= 1.4.5)
|
||||
factory_bot (6.1.0)
|
||||
@@ -261,7 +262,7 @@ GEM
|
||||
multi_json (~> 1.11)
|
||||
os (>= 0.9, < 2.0)
|
||||
signet (~> 0.14)
|
||||
groupdate (5.1.0)
|
||||
groupdate (5.2.2)
|
||||
activesupport (>= 5)
|
||||
grpc (1.37.1)
|
||||
google-protobuf (~> 3.15)
|
||||
@@ -335,7 +336,7 @@ GEM
|
||||
method_source (1.0.0)
|
||||
mime-types (3.3.1)
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2020.0512)
|
||||
mime-types-data (3.2021.0225)
|
||||
mini_magick (4.10.1)
|
||||
mini_mime (1.1.0)
|
||||
mini_portile2 (2.5.1)
|
||||
@@ -350,7 +351,7 @@ GEM
|
||||
connection_pool (~> 2.2)
|
||||
netrc (0.11.0)
|
||||
nio4r (2.5.7)
|
||||
nokogiri (1.11.3)
|
||||
nokogiri (1.11.6)
|
||||
mini_portile2 (~> 2.5.0)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.6)
|
||||
@@ -368,7 +369,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.6)
|
||||
puma (4.3.6)
|
||||
puma (4.3.8)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -584,8 +585,8 @@ GEM
|
||||
coercible (~> 1.0)
|
||||
descendants_tracker (~> 0.0, >= 0.0.3)
|
||||
equalizer (~> 0.0, >= 0.0.9)
|
||||
warden (1.2.8)
|
||||
rack (>= 2.0.6)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.0.4)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
@@ -613,7 +614,6 @@ PLATFORMS
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
action-cable-testing
|
||||
activerecord-import
|
||||
acts-as-taggable-on
|
||||
administrate
|
||||
@@ -632,9 +632,10 @@ DEPENDENCIES
|
||||
cypress-on-rails (~> 1.0)
|
||||
database_cleaner
|
||||
devise
|
||||
devise-secure_password (~> 2.0)
|
||||
devise_token_auth
|
||||
dotenv-rails
|
||||
facebook-messenger (= 1.5.0)
|
||||
facebook-messenger
|
||||
factory_bot_rails
|
||||
faker
|
||||
fcm
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
require 'facebook/messenger'
|
||||
|
||||
class FacebookBot
|
||||
include Facebook::Messenger
|
||||
|
||||
Bot.on :message do |message|
|
||||
Facebook::Messenger::Bot.on :message do |message|
|
||||
Rails.logger.info "MESSAGE_RECIEVED #{message}"
|
||||
response = ::Integrations::Facebook::MessageParser.new(message)
|
||||
::Integrations::Facebook::MessageCreator.new(response).perform
|
||||
end
|
||||
|
||||
Bot.on :delivery do |delivery|
|
||||
Facebook::Messenger::Bot.on :delivery do |delivery|
|
||||
# delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38'
|
||||
# delivery.sender # => { 'id' => '1008372609250235' }
|
||||
# delivery.recipient # => { 'id' => '2015573629214912' }
|
||||
@@ -20,7 +18,7 @@ class FacebookBot
|
||||
Rails.logger.info "Human was online at #{delivery.at}"
|
||||
end
|
||||
|
||||
Bot.on :message_echo do |message|
|
||||
Facebook::Messenger::Bot.on :message_echo do |message|
|
||||
Rails.logger.info "MESSAGE_ECHO #{message}"
|
||||
response = ::Integrations::Facebook::MessageParser.new(message)
|
||||
::Integrations::Facebook::MessageCreator.new(response).perform
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
class AccountBuilder
|
||||
include CustomExceptions::Account
|
||||
pattr_initialize [:account_name!, :email!, :confirmed!, :user, :user_full_name, :user_password]
|
||||
pattr_initialize [:account_name!, :email!, :confirmed, :user, :user_full_name, :user_password]
|
||||
|
||||
def perform
|
||||
if @user.nil?
|
||||
@@ -61,11 +61,9 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def create_user
|
||||
password = user_password || SecureRandom.alphanumeric(12)
|
||||
|
||||
@user = User.new(email: @email,
|
||||
password: password,
|
||||
password_confirmation: password,
|
||||
password: user_password,
|
||||
password_confirmation: user_password,
|
||||
name: @user_full_name)
|
||||
@user.confirm if @confirmed
|
||||
@user.save!
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ContactBuilder
|
||||
pattr_initialize [:source_id!, :inbox!, :contact_attributes!]
|
||||
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
|
||||
|
||||
def perform
|
||||
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
|
||||
@@ -18,7 +18,8 @@ class ContactBuilder
|
||||
::ContactInbox.create!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id,
|
||||
source_id: source_id
|
||||
source_id: source_id,
|
||||
hmac_verified: hmac_verified || false
|
||||
)
|
||||
end
|
||||
|
||||
@@ -28,7 +29,7 @@ class ContactBuilder
|
||||
|
||||
def create_contact
|
||||
account.contacts.create!(
|
||||
name: contact_attributes[:name],
|
||||
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
|
||||
phone_number: contact_attributes[:phone_number],
|
||||
email: contact_attributes[:email],
|
||||
identifier: contact_attributes[:identifier],
|
||||
|
||||
@@ -13,6 +13,7 @@ class Messages::Facebook::MessageBuilder
|
||||
@outgoing_echo = outgoing_echo
|
||||
@sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id)
|
||||
@message_type = (@outgoing_echo ? :outgoing : :incoming)
|
||||
@attachments = (@response.attachments || [])
|
||||
end
|
||||
|
||||
def perform
|
||||
@@ -41,13 +42,19 @@ class Messages::Facebook::MessageBuilder
|
||||
|
||||
def build_message
|
||||
@message = conversation.messages.create!(message_params)
|
||||
(response.attachments || []).each do |attachment|
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
@attachments.each do |attachment|
|
||||
process_attachment(attachment)
|
||||
end
|
||||
end
|
||||
|
||||
def process_attachment(attachment)
|
||||
return if attachment['type'].to_sym == :template
|
||||
|
||||
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
|
||||
attachment_obj.save!
|
||||
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
|
||||
end
|
||||
|
||||
def attach_file(attachment, file_url)
|
||||
file_resource = LocalResource.new(file_url)
|
||||
attachment.file.attach(io: file_resource.file, filename: file_resource.filename, content_type: file_resource.encoding)
|
||||
|
||||
@@ -39,7 +39,17 @@ class Messages::MessageBuilder
|
||||
end
|
||||
|
||||
def sender
|
||||
message_type == 'outgoing' ? @user : @conversation.contact
|
||||
message_type == 'outgoing' ? (message_sender || @user) : @conversation.contact
|
||||
end
|
||||
|
||||
def external_created_at
|
||||
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
|
||||
end
|
||||
|
||||
def message_sender
|
||||
return if @params[:sender_type] != 'AgentBot'
|
||||
|
||||
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
@@ -54,6 +64,6 @@ class Messages::MessageBuilder
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id]
|
||||
}
|
||||
}.merge(external_created_at)
|
||||
end
|
||||
end
|
||||
|
||||
5
app/controllers/android_app_controller.rb
Normal file
5
app/controllers/android_app_controller.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
class AndroidAppController < ApplicationController
|
||||
def assetlinks
|
||||
render layout: false
|
||||
end
|
||||
end
|
||||
@@ -16,4 +16,8 @@ class Api::BaseController < ApplicationController
|
||||
|
||||
authorize(model)
|
||||
end
|
||||
|
||||
def check_admin_authorization?
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
|
||||
35
app/controllers/api/v1/accounts/agent_bots_controller.rb
Normal file
35
app/controllers/api/v1/accounts/agent_bots_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :check_authorization
|
||||
before_action :agent_bot, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@agent_bot = Current.account.agent_bots.create!(permitted_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@agent_bot.update!(permitted_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@agent_bot.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_bot
|
||||
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
|
||||
@agent_bot ||= Current.account.agent_bots.find(params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:name, :description, :outgoing_url)
|
||||
end
|
||||
end
|
||||
@@ -38,6 +38,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@user = User.find_by(email: new_agent_params[:email])
|
||||
end
|
||||
|
||||
# TODO: move this to a builder and combine the save account user method into a builder
|
||||
# ensure the account user association is also created in a single transaction
|
||||
def create_user
|
||||
return if @user
|
||||
|
||||
@@ -58,9 +60,10 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
time = Time.now.to_i
|
||||
# intial string ensures the password requirements are met
|
||||
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
|
||||
params.require(:agent).permit(:email, :name, :role)
|
||||
.merge!(password: time, password_confirmation: time, inviter: current_user)
|
||||
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
|
||||
end
|
||||
|
||||
def agents
|
||||
|
||||
@@ -10,6 +10,11 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
||||
@campaign = Current.account.campaigns.create!(campaign_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@campaign.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
|
||||
@@ -11,6 +11,7 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
|
||||
|
||||
def ensure_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def ensure_contact
|
||||
|
||||
@@ -8,9 +8,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
|
||||
private
|
||||
|
||||
def inbox_ids
|
||||
if Current.user.administrator?
|
||||
Current.account.inboxes.pluck(:id)
|
||||
elsif Current.user.agent?
|
||||
if Current.user.administrator? || Current.user.agent?
|
||||
Current.user.assigned_inboxes.pluck(:id)
|
||||
else
|
||||
[]
|
||||
|
||||
@@ -48,7 +48,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
def show; end
|
||||
|
||||
def contactable_inboxes
|
||||
@contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
|
||||
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
|
||||
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -5,5 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
|
||||
include Events::Types
|
||||
|
||||
before_action :conversation, except: [:index]
|
||||
before_action :conversation, except: [:index, :meta, :search, :create]
|
||||
before_action :contact_inbox, only: [:create]
|
||||
|
||||
def index
|
||||
@@ -41,7 +41,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def transcript
|
||||
ConversationReplyMailer.conversation_transcript(@conversation, params[:email])&.deliver_later if params[:email].present?
|
||||
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
|
||||
|
||||
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -77,34 +79,40 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:id])
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
|
||||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
|
||||
def contact_inbox
|
||||
@contact_inbox = build_contact_inbox
|
||||
|
||||
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
|
||||
authorize @contact_inbox.inbox, :show?
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
return if params[:contact_id].blank? || params[:inbox_id].blank?
|
||||
|
||||
inbox = Current.account.inboxes.find(params[:inbox_id])
|
||||
authorize inbox, :show?
|
||||
|
||||
ContactInboxBuilder.new(
|
||||
contact_id: params[:contact_id],
|
||||
inbox_id: params[:inbox_id],
|
||||
inbox_id: inbox.id,
|
||||
source_id: params[:source_id]
|
||||
).perform
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
additional_attributes = params[:additional_attributes]&.permit! || {}
|
||||
status = params[:status].present? ? { status: params[:status] } : {}
|
||||
{
|
||||
account_id: Current.account.id,
|
||||
inbox_id: @contact_inbox.inbox_id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
}.merge(status)
|
||||
end
|
||||
|
||||
def conversation_finder
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
class Api::V1::Accounts::FacebookIndicatorsController < Api::V1::Accounts::BaseController
|
||||
before_action :set_access_token
|
||||
around_action :handle_with_exception
|
||||
|
||||
def mark_seen
|
||||
fb_bot.deliver(payload('mark_seen'), access_token: @access_token)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def typing_on
|
||||
fb_bot.deliver(payload('typing_on'), access_token: @access_token)
|
||||
head :ok
|
||||
end
|
||||
|
||||
def typing_off
|
||||
fb_bot.deliver(payload('typing_off'), access_token: @access_token)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fb_bot
|
||||
::Facebook::Messenger::Bot
|
||||
end
|
||||
|
||||
def handle_with_exception
|
||||
yield
|
||||
rescue Facebook::Messenger::Error => e
|
||||
Rails.logger.debug "Rescued: #{e.inspect}"
|
||||
true
|
||||
end
|
||||
|
||||
def payload(action)
|
||||
{
|
||||
recipient: { id: contact.source_id },
|
||||
sender_action: action
|
||||
}
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
end
|
||||
|
||||
def set_access_token
|
||||
@access_token = inbox.channel.page_access_token
|
||||
end
|
||||
|
||||
def contact
|
||||
@contact ||= inbox.contact_inboxes.find_by!(contact_id: permitted_params[:contact_id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:inbox_id, :contact_id)
|
||||
end
|
||||
end
|
||||
@@ -3,15 +3,19 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
|
||||
before_action :current_agents_ids, only: [:create]
|
||||
|
||||
def create
|
||||
# update also done via same action
|
||||
update_agents_list
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug "Rescued: #{e.inspect}"
|
||||
render_could_not_create_error('Could not add agents to inbox')
|
||||
authorize @inbox, :create?
|
||||
begin
|
||||
# update also done via same action
|
||||
update_agents_list
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.debug "Rescued: #{e.inspect}"
|
||||
render_could_not_create_error('Could not add agents to inbox')
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
authorize @inbox, :show?
|
||||
@agents = Current.account.users.where(id: @inbox.members.select(:user_id))
|
||||
end
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
update_channel_feature_flags
|
||||
end
|
||||
|
||||
def agent_bot
|
||||
@agent_bot = @inbox.agent_bot
|
||||
end
|
||||
|
||||
def set_agent_bot
|
||||
if @agent_bot
|
||||
agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox)
|
||||
@@ -58,6 +62,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(params[:id])
|
||||
authorize @inbox, :show?
|
||||
end
|
||||
|
||||
def fetch_agent_bot
|
||||
@@ -83,12 +88,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, channel:
|
||||
params.permit(:id, :avatar, :name, :greeting_message, :greeting_enabled, :enable_email_collect, channel:
|
||||
[:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email, :reply_time])
|
||||
end
|
||||
|
||||
def inbox_update_params
|
||||
params.permit(:enable_auto_assignment, :name, :avatar, :greeting_message, :greeting_enabled,
|
||||
params.permit(:enable_auto_assignment, :enable_email_collect, :name, :avatar, :greeting_message, :greeting_enabled,
|
||||
:working_hours_enabled, :out_of_office_message, :timezone,
|
||||
channel: [
|
||||
:website_url,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_apps, only: [:index]
|
||||
before_action :fetch_app, only: [:show]
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController
|
||||
before_action :check_admin_authorization?
|
||||
before_action :fetch_hook, only: [:update, :destroy]
|
||||
|
||||
def create
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
account_name: account_params[:account_name],
|
||||
user_full_name: account_params[:user_full_name],
|
||||
email: account_params[:email],
|
||||
confirmed: confirmed?,
|
||||
user_password: account_params[:password],
|
||||
user: current_user
|
||||
).perform
|
||||
if @user
|
||||
@@ -46,17 +46,13 @@ class Api::V1::AccountsController < Api::BaseController
|
||||
|
||||
private
|
||||
|
||||
def confirmed?
|
||||
super_admin? && params[:confirmed]
|
||||
end
|
||||
|
||||
def fetch_account
|
||||
@account = current_user.accounts.find(params[:id])
|
||||
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(:account_name, :email, :name, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
|
||||
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
|
||||
end
|
||||
|
||||
def check_signup_enabled
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
class Api::V1::AgentBotsController < Api::BaseController
|
||||
skip_before_action :authenticate_user!
|
||||
|
||||
def index
|
||||
render json: AgentBot.all
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,12 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
if password_params[:password].present?
|
||||
render_could_not_create_error('Invalid current password') and return unless @user.valid_password?(password_params[:current_password])
|
||||
|
||||
@user.update!(password_params.except(:current_password))
|
||||
end
|
||||
|
||||
@user.update!(profile_params)
|
||||
end
|
||||
|
||||
@@ -20,11 +26,17 @@ class Api::V1::ProfilesController < Api::BaseController
|
||||
:email,
|
||||
:name,
|
||||
:display_name,
|
||||
:password,
|
||||
:password_confirmation,
|
||||
:avatar,
|
||||
:availability,
|
||||
ui_settings: {}
|
||||
)
|
||||
end
|
||||
|
||||
def password_params
|
||||
params.require(:profile).permit(
|
||||
:current_password,
|
||||
:password,
|
||||
:password_confirmation
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
|
||||
def transcript
|
||||
if permitted_params[:email].present? && conversation.present?
|
||||
ConversationReplyMailer.conversation_transcript(
|
||||
ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
|
||||
conversation,
|
||||
permitted_params[:email]
|
||||
)&.deliver_later
|
||||
|
||||
@@ -20,7 +20,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
@message.update!(message_update_params[:message])
|
||||
end
|
||||
rescue StandardError => e
|
||||
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
||||
render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error
|
||||
end
|
||||
|
||||
private
|
||||
@@ -58,6 +58,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
# timestamp parameter is used in create conversation method
|
||||
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
end
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
before_action :check_authorization
|
||||
|
||||
def account
|
||||
builder = V2::ReportBuilder.new(Current.account, account_report_params)
|
||||
data = builder.build
|
||||
@@ -23,6 +25,10 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
||||
def account_summary_params
|
||||
{
|
||||
type: :account,
|
||||
|
||||
@@ -17,13 +17,8 @@ module AccessTokenAuthHelper
|
||||
Current.user = @resource if current_user.is_a?(User)
|
||||
end
|
||||
|
||||
def super_admin?
|
||||
@resource.present? && @resource.is_a?(SuperAdmin)
|
||||
end
|
||||
|
||||
def validate_bot_access_token!
|
||||
return if Current.user.is_a?(User)
|
||||
return if super_admin?
|
||||
return if agent_bot_accessible?
|
||||
|
||||
render_unauthorized('Access to this endpoint is not authorized for bots')
|
||||
|
||||
@@ -21,7 +21,9 @@ class DashboardController < ActionController::Base
|
||||
'PRIVACY_URL',
|
||||
'DISPLAY_MANIFEST',
|
||||
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
|
||||
'CHATWOOT_INBOX_TOKEN'
|
||||
'CHATWOOT_INBOX_TOKEN',
|
||||
'API_CHANNEL_NAME',
|
||||
'API_CHANNEL_THUMBNAIL'
|
||||
).merge(
|
||||
APP_VERSION: Chatwoot.config[:version]
|
||||
)
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
|
||||
include AuthHelper
|
||||
skip_before_action :require_no_authentication, raise: false
|
||||
skip_before_action :authenticate_user!, raise: false
|
||||
|
||||
def create
|
||||
@confirmable = User.find_by(confirmation_token: params[:confirmation_token])
|
||||
render_confirmation_success and return if @confirmable&.confirm
|
||||
|
||||
if confirm
|
||||
render_confirmation_success
|
||||
else
|
||||
render_confirmation_error
|
||||
end
|
||||
render_confirmation_error
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def confirm
|
||||
@confirmable&.confirm || (@confirmable&.confirmed_at && @confirmable&.reset_password_token)
|
||||
end
|
||||
private
|
||||
|
||||
def render_confirmation_success
|
||||
render json: { "message": 'Success', "redirect_url": create_reset_token_link(@confirmable) }, status: :ok
|
||||
send_auth_headers(@confirmable)
|
||||
render partial: 'devise/auth.json', locals: { resource: @confirmable }
|
||||
end
|
||||
|
||||
def render_confirmation_error
|
||||
if @confirmable.blank?
|
||||
render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422
|
||||
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
|
||||
elsif @confirmable.confirmed_at
|
||||
render json: { "message": 'Already confirmed', "redirect_url": '/' }, status: 422
|
||||
render json: { message: 'Already confirmed', redirect_url: '/' }, status: :unprocessable_entity
|
||||
else
|
||||
render json: { "message": 'Failure', "redirect_url": '/' }, status: 422
|
||||
render json: { message: 'Failure', redirect_url: '/' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
send_auth_headers(@recoverable)
|
||||
render partial: 'devise/auth.json', locals: { resource: @recoverable }
|
||||
else
|
||||
render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422
|
||||
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,7 +27,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
private
|
||||
|
||||
def reset_password_and_confirmation(recoverable)
|
||||
recoverable.confirm unless recoverable.confirmed? # confirm if user resets password without confirming anytime before
|
||||
@@ -40,7 +40,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
|
||||
|
||||
def build_response(message, status)
|
||||
render json: {
|
||||
"message": message
|
||||
message: message
|
||||
}, status: status
|
||||
end
|
||||
end
|
||||
|
||||
35
app/controllers/platform/api/v1/agent_bots_controller.rb
Normal file
35
app/controllers/platform/api/v1/agent_bots_controller.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
class Platform::Api::V1::AgentBotsController < PlatformController
|
||||
before_action :set_resource, except: [:index, :create]
|
||||
before_action :validate_platform_app_permissible, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
|
||||
end
|
||||
|
||||
def create
|
||||
@resource = AgentBot.new(agent_bot_params)
|
||||
@resource.save!
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
@resource.update!(agent_bot_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@resource.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_resource
|
||||
@resource = AgentBot.find(params[:id])
|
||||
end
|
||||
|
||||
def agent_bot_params
|
||||
params.permit(:name, :description, :account_id, :outgoing_url)
|
||||
end
|
||||
end
|
||||
48
app/controllers/public/api/v1/inboxes/contacts_controller.rb
Normal file
48
app/controllers/public/api/v1/inboxes/contacts_controller.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
|
||||
before_action :contact_inbox, except: [:create]
|
||||
before_action :process_hmac
|
||||
|
||||
def create
|
||||
source_id = params[:source_id] || SecureRandom.uuid
|
||||
@contact_inbox = ::ContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: @inbox_channel.inbox,
|
||||
contact_attributes: permitted_params.except(:identifier, :identifier_hash)
|
||||
).perform
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def update
|
||||
contact_identify_action = ContactIdentifyAction.new(
|
||||
contact: @contact_inbox.contact,
|
||||
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
|
||||
)
|
||||
render json: contact_identify_action.perform
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contact_inbox
|
||||
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:id])
|
||||
end
|
||||
|
||||
def process_hmac
|
||||
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
|
||||
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
|
||||
|
||||
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
|
||||
end
|
||||
|
||||
def valid_hmac?
|
||||
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
|
||||
'sha256',
|
||||
@inbox_channel.hmac_token,
|
||||
params[:identifier].to_s
|
||||
)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
|
||||
def index
|
||||
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
|
||||
end
|
||||
|
||||
def create
|
||||
@conversation = create_conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation
|
||||
::Conversation.create!(conversation_params)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @contact_inbox.contact.account_id,
|
||||
inbox_id: @contact_inbox.inbox_id,
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id
|
||||
}
|
||||
end
|
||||
end
|
||||
68
app/controllers/public/api/v1/inboxes/messages_controller.rb
Normal file
68
app/controllers/public/api/v1/inboxes/messages_controller.rb
Normal file
@@ -0,0 +1,68 @@
|
||||
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
|
||||
before_action :set_message, only: [:update]
|
||||
|
||||
def index
|
||||
@messages = @conversation.nil? ? [] : message_finder.perform
|
||||
end
|
||||
|
||||
def create
|
||||
@message = @conversation.messages.new(message_params)
|
||||
@message.save
|
||||
build_attachment
|
||||
end
|
||||
|
||||
def update
|
||||
@message.update!(message_update_params)
|
||||
rescue StandardError => e
|
||||
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_attachment
|
||||
return if params[:attachments].blank?
|
||||
|
||||
params[:attachments].each do |uploaded_attachment|
|
||||
attachment = @message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: helpers.file_type(uploaded_attachment&.content_type)
|
||||
)
|
||||
attachment.file.attach(uploaded_attachment)
|
||||
end
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def message_finder_params
|
||||
{
|
||||
filter_internal_messages: true,
|
||||
before: params[:before]
|
||||
}
|
||||
end
|
||||
|
||||
def message_finder
|
||||
@message_finder ||= MessageFinder.new(@conversation, message_finder_params)
|
||||
end
|
||||
|
||||
def message_update_params
|
||||
params.permit(submitted_values: [:name, :title, :value])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:content, :echo_id)
|
||||
end
|
||||
|
||||
def set_message
|
||||
@message = @conversation.messages.find(params[:id])
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: @conversation.account_id,
|
||||
sender: @contact_inbox.contact,
|
||||
content: permitted_params[:content],
|
||||
inbox_id: @conversation.inbox_id,
|
||||
echo_id: permitted_params[:echo_id],
|
||||
message_type: :incoming
|
||||
}
|
||||
end
|
||||
end
|
||||
23
app/controllers/public/api/v1/inboxes_controller.rb
Normal file
23
app/controllers/public/api/v1/inboxes_controller.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class Public::Api::V1::InboxesController < PublicController
|
||||
before_action :set_inbox_channel
|
||||
before_action :set_contact_inbox
|
||||
before_action :set_conversation
|
||||
|
||||
private
|
||||
|
||||
def set_inbox_channel
|
||||
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
|
||||
end
|
||||
|
||||
def set_contact_inbox
|
||||
return if params[:contact_id].blank?
|
||||
|
||||
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:contact_id])
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
return if params[:conversation_id].blank?
|
||||
|
||||
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
|
||||
end
|
||||
end
|
||||
3
app/controllers/public_controller.rb
Normal file
3
app/controllers/public_controller.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
class PublicController < ActionController::Base
|
||||
skip_before_action :verify_authenticity_token
|
||||
end
|
||||
@@ -15,8 +15,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
description: Field::String,
|
||||
outgoing_url: Field::String,
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime,
|
||||
hide_input_for_bot_conversations: Field::Boolean
|
||||
updated_at: Field::DateTime
|
||||
}.freeze
|
||||
|
||||
# COLLECTION_ATTRIBUTES
|
||||
@@ -39,7 +38,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
name
|
||||
description
|
||||
outgoing_url
|
||||
hide_input_for_bot_conversations
|
||||
].freeze
|
||||
|
||||
# FORM_ATTRIBUTES
|
||||
@@ -49,7 +47,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
|
||||
name
|
||||
description
|
||||
outgoing_url
|
||||
hide_input_for_bot_conversations
|
||||
].freeze
|
||||
|
||||
# COLLECTION_FILTERS
|
||||
|
||||
@@ -11,7 +11,6 @@ class SuperAdminDashboard < Administrate::BaseDashboard
|
||||
id: Field::Number,
|
||||
email: Field::String,
|
||||
password: Field::Password,
|
||||
access_token: Field::HasOne,
|
||||
remember_created_at: Field::DateTime,
|
||||
sign_in_count: Field::Number,
|
||||
current_sign_in_at: Field::DateTime,
|
||||
@@ -30,7 +29,6 @@ class SuperAdminDashboard < Administrate::BaseDashboard
|
||||
COLLECTION_ATTRIBUTES = %i[
|
||||
id
|
||||
email
|
||||
access_token
|
||||
].freeze
|
||||
|
||||
# SHOW_PAGE_ATTRIBUTES
|
||||
|
||||
@@ -48,15 +48,11 @@ class ConversationFinder
|
||||
private
|
||||
|
||||
def set_inboxes
|
||||
if params[:inbox_id]
|
||||
@inbox_ids = current_account.inboxes.where(id: params[:inbox_id])
|
||||
else
|
||||
if @current_user.administrator?
|
||||
@inbox_ids = current_account.inboxes.pluck(:id)
|
||||
elsif @current_user.agent?
|
||||
@inbox_ids = @current_user.assigned_inboxes.pluck(:id)
|
||||
end
|
||||
end
|
||||
@inbox_ids = if params[:inbox_id]
|
||||
current_account.inboxes.where(id: params[:inbox_id])
|
||||
else
|
||||
@current_user.assigned_inboxes.pluck(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def set_assignee_type
|
||||
|
||||
@@ -29,6 +29,7 @@ export default {
|
||||
account_name: creds.accountName.trim(),
|
||||
user_full_name: creds.fullName.trim(),
|
||||
email: creds.email,
|
||||
password: creds.password,
|
||||
})
|
||||
.then(response => {
|
||||
setAuthCredentials(response);
|
||||
@@ -95,8 +96,18 @@ export default {
|
||||
},
|
||||
|
||||
verifyPasswordToken({ confirmationToken }) {
|
||||
return axios.post('auth/confirmation', {
|
||||
confirmation_token: confirmationToken,
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
.post('auth/confirmation', {
|
||||
confirmation_token: confirmationToken,
|
||||
})
|
||||
.then(response => {
|
||||
setAuthCredentials(response);
|
||||
resolve(response);
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error.response);
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
9
app/javascript/dashboard/api/contactNotes.js
Normal file
9
app/javascript/dashboard/api/contactNotes.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class ContactNotes extends ApiClient {
|
||||
constructor() {
|
||||
super('contact_notes', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactNotes();
|
||||
@@ -18,6 +18,14 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
|
||||
}
|
||||
|
||||
getContactLabels(contactId) {
|
||||
return axios.get(`${this.url}/${contactId}/labels`);
|
||||
}
|
||||
|
||||
updateContactLabels(contactId, labels) {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
search(search = '', page = 1, sortAttr = 'name') {
|
||||
return axios.get(
|
||||
`${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}`
|
||||
|
||||
@@ -16,6 +16,14 @@ class IntegrationsAPI extends ApiClient {
|
||||
delete(integrationId) {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/${integrationId}`);
|
||||
}
|
||||
|
||||
createHook(hookData) {
|
||||
return axios.post(`${this.baseUrl()}/integrations/hooks`, hookData);
|
||||
}
|
||||
|
||||
deleteHook(hookId) {
|
||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new IntegrationsAPI();
|
||||
|
||||
26
app/javascript/dashboard/api/specs/account.spec.js
Normal file
26
app/javascript/dashboard/api/specs/account.spec.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import accountAPI from '../account';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#accountAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(accountAPI).toBeInstanceOf(ApiClient);
|
||||
expect(accountAPI).toHaveProperty('get');
|
||||
expect(accountAPI).toHaveProperty('show');
|
||||
expect(accountAPI).toHaveProperty('create');
|
||||
expect(accountAPI).toHaveProperty('update');
|
||||
expect(accountAPI).toHaveProperty('delete');
|
||||
expect(accountAPI).toHaveProperty('createAccount');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#createAccount', () => {
|
||||
accountAPI.createAccount({
|
||||
name: 'Chatwoot',
|
||||
});
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith('/api/v1/accounts', {
|
||||
name: 'Chatwoot',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
27
app/javascript/dashboard/api/specs/apiSpecHelper.js
Normal file
27
app/javascript/dashboard/api/specs/apiSpecHelper.js
Normal file
@@ -0,0 +1,27 @@
|
||||
function apiSpecHelper() {
|
||||
beforeEach(() => {
|
||||
this.originalAxios = window.axios;
|
||||
this.axiosMock = {
|
||||
post: jest.fn(() => Promise.resolve()),
|
||||
get: jest.fn(() => Promise.resolve()),
|
||||
patch: jest.fn(() => Promise.resolve()),
|
||||
delete: jest.fn(() => Promise.resolve()),
|
||||
};
|
||||
window.axios = this.axiosMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = this.originalAxios;
|
||||
});
|
||||
}
|
||||
// https://stackoverflow.com/a/59344023/3901856
|
||||
const sharedWrapper = describe('sharedWrapper', () => {});
|
||||
export default function describeWithAPIMock(skillName, testFn) {
|
||||
return describe(skillName, function configureContext() {
|
||||
function Context() {}
|
||||
Context.prototype = sharedWrapper.ctx;
|
||||
this.ctx = new Context();
|
||||
apiSpecHelper.call(this);
|
||||
testFn.call(this, this);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import fbChannel from '../../channel/fbChannel';
|
||||
import ApiClient from '../../ApiClient';
|
||||
import describeWithAPIMock from '../apiSpecHelper';
|
||||
|
||||
describe('#FBChannel', () => {
|
||||
it('creates correct instance', () => {
|
||||
@@ -10,4 +11,29 @@ describe('#FBChannel', () => {
|
||||
expect(fbChannel).toHaveProperty('update');
|
||||
expect(fbChannel).toHaveProperty('delete');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#create', () => {
|
||||
fbChannel.create({ omniauthToken: 'ASFM131CSF@#@$', appId: 'chatwoot' });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/callbacks/register_facebook_page',
|
||||
{
|
||||
omniauthToken: 'ASFM131CSF@#@$',
|
||||
appId: 'chatwoot',
|
||||
}
|
||||
);
|
||||
});
|
||||
it('#reauthorize', () => {
|
||||
fbChannel.reauthorizeFacebookPage({
|
||||
omniauthToken: 'ASFM131CSF@#@$',
|
||||
inboxId: 1,
|
||||
});
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/callbacks/reauthorize_page',
|
||||
{
|
||||
omniauth_token: 'ASFM131CSF@#@$',
|
||||
inbox_id: 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import twilioChannel from '../../channel/twilioChannel';
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
describe('#twilioChannel', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(twilioChannel).toBeInstanceOf(ApiClient);
|
||||
expect(twilioChannel).toHaveProperty('get');
|
||||
expect(twilioChannel).toHaveProperty('show');
|
||||
expect(twilioChannel).toHaveProperty('create');
|
||||
expect(twilioChannel).toHaveProperty('update');
|
||||
expect(twilioChannel).toHaveProperty('delete');
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import TwitterClient from '../../channel/twitterClient';
|
||||
import twitterClient from '../../channel/twitterClient';
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
describe('#TwitterClient', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(TwitterClient).toBeInstanceOf(ApiClient);
|
||||
expect(TwitterClient).toHaveProperty('generateAuthorization');
|
||||
expect(twitterClient).toBeInstanceOf(ApiClient);
|
||||
expect(twitterClient).toHaveProperty('get');
|
||||
expect(twitterClient).toHaveProperty('show');
|
||||
expect(twitterClient).toHaveProperty('create');
|
||||
expect(twitterClient).toHaveProperty('update');
|
||||
expect(twitterClient).toHaveProperty('delete');
|
||||
expect(twitterClient).toHaveProperty('generateAuthorization');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import webChannelClient from '../../channel/webChannel';
|
||||
import ApiClient from '../../ApiClient';
|
||||
|
||||
describe('#webChannelClient', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(webChannelClient).toBeInstanceOf(ApiClient);
|
||||
expect(webChannelClient).toHaveProperty('get');
|
||||
expect(webChannelClient).toHaveProperty('show');
|
||||
expect(webChannelClient).toHaveProperty('create');
|
||||
expect(webChannelClient).toHaveProperty('update');
|
||||
expect(webChannelClient).toHaveProperty('delete');
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,63 @@
|
||||
import contacts from '../contacts';
|
||||
import contactAPI from '../contacts';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#ContactsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(contacts).toBeInstanceOf(ApiClient);
|
||||
expect(contacts).toHaveProperty('get');
|
||||
expect(contacts).toHaveProperty('show');
|
||||
expect(contacts).toHaveProperty('create');
|
||||
expect(contacts).toHaveProperty('update');
|
||||
expect(contacts).toHaveProperty('delete');
|
||||
expect(contacts).toHaveProperty('getConversations');
|
||||
expect(contactAPI).toBeInstanceOf(ApiClient);
|
||||
expect(contactAPI).toHaveProperty('get');
|
||||
expect(contactAPI).toHaveProperty('show');
|
||||
expect(contactAPI).toHaveProperty('create');
|
||||
expect(contactAPI).toHaveProperty('update');
|
||||
expect(contactAPI).toHaveProperty('delete');
|
||||
expect(contactAPI).toHaveProperty('getConversations');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#get', () => {
|
||||
contactAPI.get(1, 'name');
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts?page=1&sort=name'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getConversations', () => {
|
||||
contactAPI.getConversations(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/conversations'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getContactableInboxes', () => {
|
||||
contactAPI.getContactableInboxes(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/contactable_inboxes'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getContactLabels', () => {
|
||||
contactAPI.getContactLabels(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/labels'
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateContactLabels', () => {
|
||||
const labels = ['support-query'];
|
||||
contactAPI.updateContactLabels(1, labels);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/labels',
|
||||
{
|
||||
labels,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#search', () => {
|
||||
contactAPI.search('leads', 1, 'date');
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/search?q=leads&page=1&sort=date'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,36 @@
|
||||
import conversations from '../conversations';
|
||||
import conversationsAPI from '../conversations';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#ConversationApi', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(conversations).toBeInstanceOf(ApiClient);
|
||||
expect(conversations).toHaveProperty('get');
|
||||
expect(conversations).toHaveProperty('show');
|
||||
expect(conversations).toHaveProperty('create');
|
||||
expect(conversations).toHaveProperty('update');
|
||||
expect(conversations).toHaveProperty('delete');
|
||||
expect(conversations).toHaveProperty('getLabels');
|
||||
expect(conversations).toHaveProperty('updateLabels');
|
||||
expect(conversationsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(conversationsAPI).toHaveProperty('get');
|
||||
expect(conversationsAPI).toHaveProperty('show');
|
||||
expect(conversationsAPI).toHaveProperty('create');
|
||||
expect(conversationsAPI).toHaveProperty('update');
|
||||
expect(conversationsAPI).toHaveProperty('delete');
|
||||
expect(conversationsAPI).toHaveProperty('getLabels');
|
||||
expect(conversationsAPI).toHaveProperty('updateLabels');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getLabels', () => {
|
||||
conversationsAPI.getLabels(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/1/labels'
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateLabels', () => {
|
||||
const labels = ['support-query'];
|
||||
conversationsAPI.updateLabels(1, labels);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/1/labels',
|
||||
{
|
||||
labels,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
13
app/javascript/dashboard/api/specs/endPoints.spec.js
Normal file
13
app/javascript/dashboard/api/specs/endPoints.spec.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import endPoints from '../endPoints';
|
||||
|
||||
describe('#endPoints', () => {
|
||||
it('it should return register url details if register page passed ', () => {
|
||||
expect(endPoints('register')).toEqual({ url: 'api/v1/accounts.json' });
|
||||
});
|
||||
it('it should inbox url details if getInbox page passed', () => {
|
||||
expect(endPoints('getInbox')).toEqual({
|
||||
url: 'api/v1/conversations.json',
|
||||
params: { inbox_id: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import conversationAPI from '../../inbox/conversation';
|
||||
import ApiClient from '../../ApiClient';
|
||||
import describeWithAPIMock from '../apiSpecHelper';
|
||||
|
||||
describe('#ConversationAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
@@ -20,27 +21,143 @@ describe('#ConversationAPI', () => {
|
||||
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
let originalAxios = null;
|
||||
let axiosMock = null;
|
||||
|
||||
beforeEach(() => {
|
||||
originalAxios = window.axios;
|
||||
axiosMock = { post: jest.fn(() => Promise.resolve()) };
|
||||
|
||||
window.axios = axiosMock;
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#get conversations', () => {
|
||||
conversationAPI.get({
|
||||
inboxId: 1,
|
||||
status: 'open',
|
||||
assigneeType: 'me',
|
||||
page: 1,
|
||||
labels: [],
|
||||
teamId: 1,
|
||||
});
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations',
|
||||
{
|
||||
params: {
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
status: 'open',
|
||||
assignee_type: 'me',
|
||||
page: 1,
|
||||
labels: [],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.axios = originalAxios;
|
||||
it('#search', () => {
|
||||
conversationAPI.search({
|
||||
q: 'leads',
|
||||
page: 1,
|
||||
});
|
||||
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/search',
|
||||
{
|
||||
params: {
|
||||
q: 'leads',
|
||||
page: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#toggleStatus', () => {
|
||||
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/toggle_status`,
|
||||
{
|
||||
status: 'online',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#assignAgent', () => {
|
||||
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments?assignee_id=34`,
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('#assignTeam', () => {
|
||||
conversationAPI.assignTeam({ conversationId: 12, teamId: 1 });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/assignments`,
|
||||
{
|
||||
team_id: 1,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#markMessageRead', () => {
|
||||
conversationAPI.markMessageRead({ id: 12 });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/update_last_seen`
|
||||
);
|
||||
});
|
||||
|
||||
it('#toggleTyping', () => {
|
||||
conversationAPI.toggleTyping({
|
||||
conversationId: 12,
|
||||
status: 'typing_on',
|
||||
});
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/toggle_typing_status`,
|
||||
{
|
||||
typing_status: 'typing_on',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#mute', () => {
|
||||
conversationAPI.mute(45);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/45/mute'
|
||||
);
|
||||
});
|
||||
|
||||
it('#unmute', () => {
|
||||
conversationAPI.unmute(45);
|
||||
|
||||
expect(axiosMock.post).toHaveBeenCalledWith(
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/45/unmute'
|
||||
);
|
||||
});
|
||||
|
||||
it('#meta', () => {
|
||||
conversationAPI.meta({
|
||||
inboxId: 1,
|
||||
status: 'open',
|
||||
assigneeType: 'me',
|
||||
labels: [],
|
||||
teamId: 1,
|
||||
});
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/meta',
|
||||
{
|
||||
params: {
|
||||
inbox_id: 1,
|
||||
team_id: 1,
|
||||
status: 'open',
|
||||
assignee_type: 'me',
|
||||
labels: [],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#sendEmailTranscript', () => {
|
||||
conversationAPI.sendEmailTranscript({
|
||||
conversationId: 45,
|
||||
email: 'john@acme.inc',
|
||||
});
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/conversations/45/transcript',
|
||||
{
|
||||
email: 'john@acme.inc',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
32
app/javascript/dashboard/api/specs/inbox/message.spec.js
Normal file
32
app/javascript/dashboard/api/specs/inbox/message.spec.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import messageAPI from '../../inbox/message';
|
||||
import ApiClient from '../../ApiClient';
|
||||
import describeWithAPIMock from '../apiSpecHelper';
|
||||
|
||||
describe('#ConversationAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(messageAPI).toBeInstanceOf(ApiClient);
|
||||
expect(messageAPI).toHaveProperty('get');
|
||||
expect(messageAPI).toHaveProperty('show');
|
||||
expect(messageAPI).toHaveProperty('create');
|
||||
expect(messageAPI).toHaveProperty('update');
|
||||
expect(messageAPI).toHaveProperty('delete');
|
||||
expect(messageAPI).toHaveProperty('getPreviousMessages');
|
||||
});
|
||||
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getPreviousMessages', () => {
|
||||
messageAPI.getPreviousMessages({
|
||||
conversationId: 12,
|
||||
before: 4573,
|
||||
});
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
`/api/v1/conversations/12/messages`,
|
||||
{
|
||||
params: {
|
||||
before: 4573,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,31 @@
|
||||
import inboxes from '../inboxes';
|
||||
import inboxesAPI from '../inboxes';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#InboxesAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(inboxes).toBeInstanceOf(ApiClient);
|
||||
expect(inboxes).toHaveProperty('get');
|
||||
expect(inboxes).toHaveProperty('show');
|
||||
expect(inboxes).toHaveProperty('create');
|
||||
expect(inboxes).toHaveProperty('update');
|
||||
expect(inboxes).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toBeInstanceOf(ApiClient);
|
||||
expect(inboxesAPI).toHaveProperty('get');
|
||||
expect(inboxesAPI).toHaveProperty('show');
|
||||
expect(inboxesAPI).toHaveProperty('create');
|
||||
expect(inboxesAPI).toHaveProperty('update');
|
||||
expect(inboxesAPI).toHaveProperty('delete');
|
||||
expect(inboxesAPI).toHaveProperty('getAssignableAgents');
|
||||
expect(inboxesAPI).toHaveProperty('getCampaigns');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getAssignableAgents', () => {
|
||||
inboxesAPI.getAssignableAgents(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/1/assignable_agents'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getCampaigns', () => {
|
||||
inboxesAPI.getCampaigns(2);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/campaigns'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
55
app/javascript/dashboard/api/specs/integrations.spec.js
Normal file
55
app/javascript/dashboard/api/specs/integrations.spec.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import integrationAPI from '../integrations';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#integrationAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(integrationAPI).toBeInstanceOf(ApiClient);
|
||||
expect(integrationAPI).toHaveProperty('get');
|
||||
expect(integrationAPI).toHaveProperty('show');
|
||||
expect(integrationAPI).toHaveProperty('create');
|
||||
expect(integrationAPI).toHaveProperty('update');
|
||||
expect(integrationAPI).toHaveProperty('delete');
|
||||
expect(integrationAPI).toHaveProperty('connectSlack');
|
||||
expect(integrationAPI).toHaveProperty('createHook');
|
||||
expect(integrationAPI).toHaveProperty('deleteHook');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#connectSlack', () => {
|
||||
const code = 'SDNFJNSDFNDSJN';
|
||||
integrationAPI.connectSlack(code);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/slack',
|
||||
{
|
||||
code,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#delete', () => {
|
||||
integrationAPI.delete(2);
|
||||
expect(context.axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/2'
|
||||
);
|
||||
});
|
||||
|
||||
it('#createHook', () => {
|
||||
const hookData = {
|
||||
app_id: 'fullcontact',
|
||||
settings: { api_key: 'SDFSDGSVE' },
|
||||
};
|
||||
integrationAPI.createHook(hookData);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/hooks',
|
||||
hookData
|
||||
);
|
||||
});
|
||||
|
||||
it('#deleteHook', () => {
|
||||
integrationAPI.deleteHook(2);
|
||||
expect(context.axiosMock.delete).toHaveBeenCalledWith(
|
||||
'/api/v1/integrations/hooks/2'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,54 @@
|
||||
import notifications from '../notifications';
|
||||
import notificationsAPI from '../notifications';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#NotificationAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(notifications).toBeInstanceOf(ApiClient);
|
||||
expect(notifications).toHaveProperty('get');
|
||||
expect(notifications).toHaveProperty('getNotifications');
|
||||
expect(notifications).toHaveProperty('getUnreadCount');
|
||||
expect(notifications).toHaveProperty('read');
|
||||
expect(notifications).toHaveProperty('readAll');
|
||||
expect(notificationsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(notificationsAPI).toHaveProperty('get');
|
||||
expect(notificationsAPI).toHaveProperty('getNotifications');
|
||||
expect(notificationsAPI).toHaveProperty('getUnreadCount');
|
||||
expect(notificationsAPI).toHaveProperty('read');
|
||||
expect(notificationsAPI).toHaveProperty('readAll');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#get', () => {
|
||||
notificationsAPI.get(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/notifications?page=1'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getNotifications', () => {
|
||||
notificationsAPI.getNotifications(1);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/notifications/1/notifications'
|
||||
);
|
||||
});
|
||||
|
||||
it('#getUnreadCount', () => {
|
||||
notificationsAPI.getUnreadCount();
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/notifications/unread_count'
|
||||
);
|
||||
});
|
||||
|
||||
it('#read', () => {
|
||||
notificationsAPI.read(48670, 'Conversation');
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/notifications/read_all',
|
||||
{
|
||||
primary_actor_id: 'Conversation',
|
||||
primary_actor_type: 48670,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#readAll', () => {
|
||||
notificationsAPI.readAll();
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/notifications/read_all'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
import reports from '../reports';
|
||||
import reportsAPI from '../reports';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#Reports API', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(reports).toBeInstanceOf(ApiClient);
|
||||
expect(reports.apiVersion).toBe('/api/v2');
|
||||
expect(reports).toHaveProperty('get');
|
||||
expect(reports).toHaveProperty('show');
|
||||
expect(reports).toHaveProperty('create');
|
||||
expect(reports).toHaveProperty('update');
|
||||
expect(reports).toHaveProperty('delete');
|
||||
expect(reports).toHaveProperty('getAccountReports');
|
||||
expect(reports).toHaveProperty('getAccountSummary');
|
||||
expect(reports).toHaveProperty('getAgentReports');
|
||||
expect(reportsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(reportsAPI.apiVersion).toBe('/api/v2');
|
||||
expect(reportsAPI).toHaveProperty('get');
|
||||
expect(reportsAPI).toHaveProperty('show');
|
||||
expect(reportsAPI).toHaveProperty('create');
|
||||
expect(reportsAPI).toHaveProperty('update');
|
||||
expect(reportsAPI).toHaveProperty('delete');
|
||||
expect(reportsAPI).toHaveProperty('getAccountReports');
|
||||
expect(reportsAPI).toHaveProperty('getAccountSummary');
|
||||
expect(reportsAPI).toHaveProperty('getAgentReports');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getAccountReports', () => {
|
||||
reportsAPI.getAccountReports(
|
||||
'conversations_count',
|
||||
1621103400,
|
||||
1621621800
|
||||
);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/account',
|
||||
{
|
||||
params: {
|
||||
metric: 'conversations_count',
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getAccountSummary', () => {
|
||||
reportsAPI.getAccountSummary(1621103400, 1621621800);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/account_summary',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getAgentReports', () => {
|
||||
reportsAPI.getAgentReports(1621103400, 1621621800);
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/agents',
|
||||
{
|
||||
params: {
|
||||
since: 1621103400,
|
||||
until: 1621621800,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
import teams from '../teams';
|
||||
import teamsAPI from '../teams';
|
||||
import ApiClient from '../ApiClient';
|
||||
import describeWithAPIMock from './apiSpecHelper';
|
||||
|
||||
describe('#TeamsAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(teams).toBeInstanceOf(ApiClient);
|
||||
expect(teams).toHaveProperty('get');
|
||||
expect(teams).toHaveProperty('show');
|
||||
expect(teams).toHaveProperty('create');
|
||||
expect(teams).toHaveProperty('update');
|
||||
expect(teams).toHaveProperty('delete');
|
||||
expect(teams).toHaveProperty('getAgents');
|
||||
expect(teams).toHaveProperty('addAgents');
|
||||
expect(teams).toHaveProperty('updateAgents');
|
||||
expect(teamsAPI).toBeInstanceOf(ApiClient);
|
||||
expect(teamsAPI).toHaveProperty('get');
|
||||
expect(teamsAPI).toHaveProperty('show');
|
||||
expect(teamsAPI).toHaveProperty('create');
|
||||
expect(teamsAPI).toHaveProperty('update');
|
||||
expect(teamsAPI).toHaveProperty('delete');
|
||||
expect(teamsAPI).toHaveProperty('getAgents');
|
||||
expect(teamsAPI).toHaveProperty('addAgents');
|
||||
expect(teamsAPI).toHaveProperty('updateAgents');
|
||||
});
|
||||
describeWithAPIMock('API calls', context => {
|
||||
it('#getAgents', () => {
|
||||
teamsAPI.getAgents({ teamId: 1 });
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/teams/1/team_members'
|
||||
);
|
||||
});
|
||||
|
||||
it('#addAgents', () => {
|
||||
teamsAPI.addAgents({ teamId: 1, agentsList: { user_ids: [1, 10, 21] } });
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/teams/1/team_members',
|
||||
{
|
||||
user_ids: { user_ids: [1, 10, 21] },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateAgents', () => {
|
||||
const agentsList = { user_ids: [1, 10, 21] };
|
||||
teamsAPI.updateAgents({
|
||||
teamId: 1,
|
||||
agentsList,
|
||||
});
|
||||
expect(context.axiosMock.patch).toHaveBeenCalledWith(
|
||||
'/api/v1/teams/1/team_members',
|
||||
{
|
||||
user_ids: agentsList,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
13
app/javascript/dashboard/api/specs/webhook.spec.js
Normal file
13
app/javascript/dashboard/api/specs/webhook.spec.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import webhooksAPI from '../webhooks';
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
describe('#webhooksAPI', () => {
|
||||
it('creates correct instance', () => {
|
||||
expect(webhooksAPI).toBeInstanceOf(ApiClient);
|
||||
expect(webhooksAPI).toHaveProperty('get');
|
||||
expect(webhooksAPI).toHaveProperty('show');
|
||||
expect(webhooksAPI).toHaveProperty('create');
|
||||
expect(webhooksAPI).toHaveProperty('update');
|
||||
expect(webhooksAPI).toHaveProperty('delete');
|
||||
});
|
||||
});
|
||||
18
app/javascript/dashboard/assets/scss/_formulate.scss
Normal file
18
app/javascript/dashboard/assets/scss/_formulate.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
@import '~dashboard/assets/scss/variables';
|
||||
|
||||
.formulate-input {
|
||||
.formulate-input-errors {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.formulate-input-error {
|
||||
color: var(--r-400);
|
||||
display: block;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: $font-weight-normal;
|
||||
margin-bottom: $space-one;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,15 @@
|
||||
&.round {
|
||||
border-radius: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
&.grey-btn {
|
||||
color: $color-gray;
|
||||
.card {
|
||||
margin-bottom: var(--space-small);
|
||||
padding: var(--space-small);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $color-light-gray;
|
||||
}
|
||||
}
|
||||
.button-wrapper .button.link.grey-btn {
|
||||
margin-left: var(--space-normal);
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
|
||||
@@ -131,7 +131,7 @@ $header-text-rendering: optimizeLegibility;
|
||||
$small-font-size: 80%;
|
||||
$header-small-font-color: $medium-gray;
|
||||
$paragraph-lineheight: 1.45;
|
||||
$paragraph-margin-bottom: 1rem;
|
||||
$paragraph-margin-bottom: var(--space-small);
|
||||
$paragraph-text-rendering: optimizeLegibility;
|
||||
$code-color: $black;
|
||||
$code-font-family: $font-family-monospace;
|
||||
@@ -238,7 +238,7 @@ $breadcrumbs-item-slash: true;
|
||||
// 11. Button
|
||||
// ----------
|
||||
|
||||
$button-padding: var(--space-one) var(--space-slab);
|
||||
$button-padding: var(--space-smaller) 1em;
|
||||
$button-margin: 0 0 $global-margin 0;
|
||||
$button-fill: solid;
|
||||
$button-background: $primary-color;
|
||||
@@ -246,7 +246,7 @@ $button-background-hover: scale-color($button-background, $lightness: -15%);
|
||||
$button-color: $white;
|
||||
$button-color-alt: $white;
|
||||
$button-radius: var(--border-radius-normal);
|
||||
$button-sizes: (tiny: var(--font-size-nano),
|
||||
$button-sizes: (tiny: var(--font-size-micro),
|
||||
small: var(--font-size-mini),
|
||||
default: var(--font-size-small),
|
||||
large: var(--font-size-medium));
|
||||
@@ -285,10 +285,10 @@ $callout-link-tint: 30%;
|
||||
$card-background: $white;
|
||||
$card-font-color: $body-font-color;
|
||||
$card-divider-background: $light-gray;
|
||||
$card-border: 1px solid $light-gray;
|
||||
$card-shadow: none;
|
||||
$card-border-radius: $global-radius;
|
||||
$card-padding: $global-padding;
|
||||
$card-border: 1px solid var(--color-border);
|
||||
$card-shadow: var(--shadow-small);
|
||||
$card-border-radius: var(--border-radius-normal);
|
||||
$card-padding: var(--space-small);
|
||||
$card-margin: $global-margin;
|
||||
|
||||
// 15. Close Button
|
||||
@@ -345,21 +345,21 @@ $fieldset-padding: $space-two;
|
||||
$fieldset-margin: $space-one $zero;
|
||||
$legend-padding: rem-calc(0 3);
|
||||
$form-spacing: $space-normal;
|
||||
$helptext-color: $header-color;
|
||||
$helptext-color: $color-body;
|
||||
$helptext-font-size: $font-size-small;
|
||||
$helptext-font-style: italic;
|
||||
$input-prefix-color: $header-color;
|
||||
$input-prefix-color: $color-body;
|
||||
$input-prefix-background: var(--b-100);
|
||||
$input-prefix-border: 1px solid $color-border;
|
||||
$input-prefix-padding: 1rem;
|
||||
$form-label-color: $header-color;
|
||||
$form-label-color: $color-body;
|
||||
$form-label-font-size: rem-calc(14);
|
||||
$form-label-font-weight: $font-weight-medium;
|
||||
$form-label-line-height: 1.8;
|
||||
$select-background: $white;
|
||||
$select-triangle-color: $dark-gray;
|
||||
$select-radius: var(--border-radius-normal);
|
||||
$input-color: $header-color;
|
||||
$input-color: $color-body;
|
||||
$input-placeholder-color: $light-gray;
|
||||
$input-font-family: inherit;
|
||||
$input-font-size: $font-size-default;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: $color-gray;
|
||||
color: var(--s-300);
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@import 'shared/assets/stylesheets/font-weights';
|
||||
@import 'shared/assets/stylesheets/shadows';
|
||||
@import 'shared/assets/stylesheets/border-radius';
|
||||
|
||||
@import 'variables';
|
||||
|
||||
@import 'mixins';
|
||||
@import 'foundation-settings';
|
||||
@import 'helper-classes';
|
||||
@import 'formulate';
|
||||
|
||||
@import 'foundation-sites/scss/foundation';
|
||||
@import '~bourbon/core/bourbon';
|
||||
|
||||
@include foundation-everything($flex: true);
|
||||
|
||||
@import 'typography';
|
||||
@import 'layout';
|
||||
@import 'animations';
|
||||
|
||||
@import 'foundation-custom';
|
||||
|
||||
@import 'widgets/buttons';
|
||||
@import 'widgets/conv-header';
|
||||
@import 'widgets/conversation-card';
|
||||
@@ -26,4 +46,4 @@
|
||||
|
||||
@import 'plugins/multiselect';
|
||||
@import 'plugins/dropdown';
|
||||
@import '@chatwoot/prosemirror-schema/src/woot-editor.css';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
@@ -1,20 +1 @@
|
||||
@import 'shared/assets/fonts/inter';
|
||||
@import 'shared/assets/stylesheets/colors';
|
||||
@import 'shared/assets/stylesheets/spacing';
|
||||
@import 'shared/assets/stylesheets/font-size';
|
||||
@import 'shared/assets/stylesheets/font-weights';
|
||||
@import 'shared/assets/stylesheets/shadows';
|
||||
@import 'shared/assets/stylesheets/border-radius';
|
||||
@import 'variables';
|
||||
|
||||
@import '~spinkit/scss/spinners/7-three-bounce';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
@import 'mixins';
|
||||
@import 'foundation-settings';
|
||||
@import 'helper-classes';
|
||||
@import 'foundation-sites/scss/foundation';
|
||||
@import '~bourbon/core/bourbon';
|
||||
|
||||
@include foundation-everything($flex: true);
|
||||
@import 'woot';
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
@import 'foundation-settings';
|
||||
@import 'helper-classes';
|
||||
@import 'foundation-sites/scss/foundation';
|
||||
|
||||
@include foundation-prototype-spacing;
|
||||
@import '~bourbon/core/bourbon';
|
||||
|
||||
@include foundation-everything($flex: true);
|
||||
|
||||
@@ -31,8 +31,9 @@
|
||||
}
|
||||
|
||||
img {
|
||||
width: 50%;
|
||||
@include margin($space-normal auto);
|
||||
flex: 1;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.channel__title{
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
.settings {
|
||||
overflow: auto;
|
||||
|
||||
.page-top-bar {
|
||||
@include padding($space-normal $space-two $zero);
|
||||
}
|
||||
}
|
||||
|
||||
// Conversation header - Light BG
|
||||
@@ -27,7 +23,6 @@
|
||||
@include flex-align($x: center, $y: middle);
|
||||
@include margin($zero);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.wizard-box {
|
||||
|
||||
@@ -1,70 +1,109 @@
|
||||
$default-button-height: 4.0rem;
|
||||
|
||||
.button {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
height: $default-button-height;
|
||||
margin-bottom: 0;
|
||||
|
||||
&.button--emoji {
|
||||
align-items: center;
|
||||
background: var(--b-50);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-large);
|
||||
.spinner {
|
||||
padding: 0 var(--space-small);
|
||||
}
|
||||
|
||||
.icon--emoji+.button__content {
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
|
||||
.icon--font+.button__content {
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
|
||||
// @TODDO - Remove after moving all buttons to woot-button
|
||||
.icon+.button__content {
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
height: var(--space-large);
|
||||
justify-content: center;
|
||||
padding: var(--space-micro);
|
||||
text-align: center;
|
||||
width: var(--space-large);
|
||||
|
||||
&:hover {
|
||||
background: var(--b-200);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
&.icon {
|
||||
padding-left: $space-normal;
|
||||
padding-right: $space-normal;
|
||||
|
||||
i {
|
||||
padding-right: $space-small;
|
||||
}
|
||||
}
|
||||
|
||||
&.nice {
|
||||
border-radius: $space-smaller;
|
||||
}
|
||||
|
||||
&.hollow {
|
||||
&.link {
|
||||
border-color: transparent;
|
||||
padding-left: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.icon {
|
||||
font-size: $font-size-default;
|
||||
}
|
||||
|
||||
&.tiny {
|
||||
font-size: $font-size-mini;
|
||||
padding: $space-small $space-slab;
|
||||
}
|
||||
|
||||
&.round {
|
||||
border-radius: $space-larger;
|
||||
}
|
||||
|
||||
// @TODO Use with link
|
||||
|
||||
&.compact {
|
||||
padding-bottom: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
// Smooth style
|
||||
&.smooth {
|
||||
@include button-style(var(--w-50), var(--w-100), var(--w-700));
|
||||
|
||||
|
||||
&.secondary {
|
||||
@include button-style(var(--s-50), var(--s-100), var(--s-700));
|
||||
}
|
||||
|
||||
&.success {
|
||||
@include button-style(var(--g-50), var(--g-100), var(--g-700));
|
||||
}
|
||||
|
||||
&.alert {
|
||||
@include button-style(var(--r-50), var(--r-100), var(--r-700));
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@include button-style(var(--y-100), var(--y-200), var(--y-900));
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes
|
||||
&.tiny {
|
||||
height: var(--space-medium);
|
||||
}
|
||||
|
||||
&.small {
|
||||
height: var(--space-large);
|
||||
}
|
||||
|
||||
&.large {
|
||||
height: var(--space-larger);
|
||||
}
|
||||
|
||||
&.button--only-icon {
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
width: $default-button-height;
|
||||
|
||||
&.tiny {
|
||||
width: var(--space-medium);
|
||||
}
|
||||
|
||||
&.small {
|
||||
width: var(--space-large);
|
||||
}
|
||||
|
||||
&.large {
|
||||
width: var(--space-larger);
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
// @TDOD move to utility file
|
||||
.button--fixed-right-top {
|
||||
position: fixed;
|
||||
right: $space-small;
|
||||
|
||||
@@ -2,11 +2,11 @@ $resolve-button-width: 13.2rem;
|
||||
|
||||
// Conversation header - Light BG
|
||||
.conv-header {
|
||||
@include padding($space-small $space-normal);
|
||||
@include background-white;
|
||||
@include flex;
|
||||
@include flex-align($x: justify, $y: middle);
|
||||
@include border-normal-bottom;
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
|
||||
.multiselect-box {
|
||||
@include flex;
|
||||
@@ -70,6 +70,7 @@ $resolve-button-width: 13.2rem;
|
||||
|
||||
|
||||
.header-actions-wrap {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
|
||||
.status--filter {
|
||||
@include padding($zero null $zero $space-normal);
|
||||
@include round-corner;
|
||||
@include margin($space-smaller $space-slab $zero $zero);
|
||||
background-color: $color-background-light;
|
||||
border: 1px solid $color-border;
|
||||
|
||||
@@ -52,25 +52,4 @@
|
||||
}
|
||||
}
|
||||
|
||||
.file-uploads>label {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover .button--emoji {
|
||||
background: var(--b-200);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-box .button--emoji.button--upload {
|
||||
padding: 0;
|
||||
|
||||
.file-uploads {
|
||||
height: 100%;
|
||||
line-height: var(--space-large);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
label {
|
||||
padding: var(--space-small);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,13 +32,14 @@
|
||||
<span class="spinner"></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
<woot-button
|
||||
v-if="!hasCurrentPageEndReached && !chatListLoading"
|
||||
class="clear button load-more-conversations"
|
||||
variant="clear"
|
||||
size="expanded"
|
||||
@click="fetchConversations"
|
||||
>
|
||||
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
|
||||
</div>
|
||||
</woot-button>
|
||||
|
||||
<p
|
||||
v-if="
|
||||
@@ -217,7 +218,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '~dashboard/assets/scss/app.scss';
|
||||
@import '~dashboard/assets/scss/woot';
|
||||
.spinner {
|
||||
margin-top: var(--space-normal);
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
class-names="resolve"
|
||||
color-scheme="success"
|
||||
icon="ion-checkmark"
|
||||
emoji="✅"
|
||||
:is-loading="isLoading"
|
||||
@click="() => toggleStatus(STATUS_TYPE.RESOLVED)"
|
||||
>
|
||||
@@ -16,6 +17,7 @@
|
||||
class-names="resolve"
|
||||
color-scheme="warning"
|
||||
icon="ion-refresh"
|
||||
emoji="👀"
|
||||
:is-loading="isLoading"
|
||||
@click="() => toggleStatus(STATUS_TYPE.OPEN)"
|
||||
>
|
||||
@@ -36,9 +38,9 @@
|
||||
:color-scheme="buttonClass"
|
||||
:disabled="isLoading"
|
||||
icon="ion-arrow-down-b"
|
||||
emoji="🔽"
|
||||
@click="openDropdown"
|
||||
>
|
||||
</woot-button>
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
|
||||
@@ -6,6 +6,7 @@ import Button from './ui/WootButton';
|
||||
import Code from './Code';
|
||||
import ColorPicker from './widgets/ColorPicker';
|
||||
import DeleteModal from './widgets/modal/DeleteModal.vue';
|
||||
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
|
||||
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import Input from './widgets/forms/Input.vue';
|
||||
@@ -42,6 +43,7 @@ const WootUIKit = {
|
||||
Tabs,
|
||||
TabsItem,
|
||||
Thumbnail,
|
||||
ConfirmDeleteModal,
|
||||
install(Vue) {
|
||||
const keys = Object.keys(this);
|
||||
keys.pop(); // remove 'install' from keys
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
class-names="status-change--dropdown-button"
|
||||
:is-disabled="status.disabled"
|
||||
@click="changeAvailabilityStatus(status.value)"
|
||||
|
||||
@@ -264,16 +264,6 @@ export default {
|
||||
.modal-container {
|
||||
width: 40rem;
|
||||
}
|
||||
|
||||
.page-top-bar {
|
||||
padding-bottom: $space-two;
|
||||
}
|
||||
}
|
||||
|
||||
.change-accounts--button.button {
|
||||
font-weight: $font-weight-normal;
|
||||
font-size: $font-size-small;
|
||||
padding: $space-small $space-one;
|
||||
}
|
||||
|
||||
.account-selector {
|
||||
|
||||
@@ -7,30 +7,42 @@
|
||||
>
|
||||
<woot-dropdown-menu>
|
||||
<woot-dropdown-item v-if="showChangeAccountOption">
|
||||
<button
|
||||
class="button clear change-accounts--button"
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
class=" change-accounts--button"
|
||||
@click="$emit('toggle-accounts')"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
|
||||
</button>
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item v-if="globalConfig.chatwootInboxToken">
|
||||
<button
|
||||
class="button clear change-accounts--button"
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
class=" change-accounts--button"
|
||||
@click="$emit('show-support-chat-window')"
|
||||
>
|
||||
Contact Support
|
||||
</button>
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<router-link :to="`/app/accounts/${accountId}/profile/settings`">
|
||||
<router-link
|
||||
:to="`/app/accounts/${accountId}/profile/settings`"
|
||||
class="button clear small change-accounts--button"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
|
||||
</router-link>
|
||||
</woot-dropdown-item>
|
||||
<woot-dropdown-item>
|
||||
<a href="#" @click.prevent="logout">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
size="small"
|
||||
class=" change-accounts--button"
|
||||
@click="logout"
|
||||
>
|
||||
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
|
||||
</a>
|
||||
</woot-button>
|
||||
</woot-dropdown-item>
|
||||
</woot-dropdown-menu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import AccountSelector from '../AccountSelector';
|
||||
import { createLocalVue, mount } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
import i18n from 'dashboard/i18n';
|
||||
|
||||
import WootModal from 'dashboard/components/Modal';
|
||||
import WootModalHeader from 'dashboard/components/ModalHeader';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.component('woot-modal', WootModal);
|
||||
localVue.component('woot-modal-header', WootModalHeader);
|
||||
|
||||
localVue.use(Vuex);
|
||||
localVue.use(VueI18n);
|
||||
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
describe('accountSelctor', () => {
|
||||
let accountSelector = null;
|
||||
const currentUser = {
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Chatwoot',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'GitX',
|
||||
role: 'agent',
|
||||
},
|
||||
],
|
||||
};
|
||||
const accountId = 1;
|
||||
const globalConfig = { createNewAccountFromDashboard: false };
|
||||
let store = null;
|
||||
let actions = null;
|
||||
let modules = null;
|
||||
|
||||
beforeEach(() => {
|
||||
actions = {};
|
||||
modules = {
|
||||
auth: {
|
||||
getters: {
|
||||
getCurrentAccountId: () => accountId,
|
||||
getCurrentUser: () => currentUser,
|
||||
},
|
||||
},
|
||||
globalConfig: {
|
||||
getters: {
|
||||
'globalConfig/get': () => globalConfig,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
actions,
|
||||
modules,
|
||||
});
|
||||
accountSelector = mount(AccountSelector, {
|
||||
store,
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
propsData: {
|
||||
showAccountModal: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('title and sub title exist', () => {
|
||||
const headerComponent = accountSelector.findComponent(WootModalHeader);
|
||||
const topBar = headerComponent.find('.page-top-bar');
|
||||
const titleComponent = topBar.find('.page-sub-title');
|
||||
expect(titleComponent.text()).toBe('Switch Account');
|
||||
const subTitleComponent = topBar.find('p');
|
||||
expect(subTitleComponent.text()).toBe(
|
||||
'Select an account from the following list'
|
||||
);
|
||||
});
|
||||
|
||||
it('first account item is checked', () => {
|
||||
const accountFirstItem = accountSelector.find('.account-selector .ion');
|
||||
expect(accountFirstItem.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import AgentDetails from '../AgentDetails';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
import i18n from 'dashboard/i18n';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
localVue.use(VueI18n);
|
||||
localVue.component('thumbnail', Thumbnail);
|
||||
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
describe('agentDetails', () => {
|
||||
const currentUser = { name: 'Neymar Junior', avatar_url: '' };
|
||||
const currentRole = 'agent';
|
||||
let store = null;
|
||||
let actions = null;
|
||||
let modules = null;
|
||||
let agentDetails = null;
|
||||
|
||||
beforeEach(() => {
|
||||
actions = {};
|
||||
|
||||
modules = {
|
||||
auth: {
|
||||
getters: {
|
||||
getCurrentUser: () => currentUser,
|
||||
getCurrentRole: () => currentRole,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
actions,
|
||||
modules,
|
||||
});
|
||||
|
||||
agentDetails = shallowMount(AgentDetails, {
|
||||
store,
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the agent name', () => {
|
||||
const agentTitle = agentDetails.find('.current-user--name');
|
||||
expect(agentTitle.text()).toBe('Neymar Junior');
|
||||
});
|
||||
|
||||
it('shows the agent role', () => {
|
||||
const agentTitle = agentDetails.find('.current-user--role');
|
||||
expect(agentTitle.text()).toBe('Agent');
|
||||
});
|
||||
|
||||
it('agent thumbnail exists', () => {
|
||||
const thumbnailComponent = agentDetails.findComponent(Thumbnail);
|
||||
expect(thumbnailComponent.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import NotificationBell from '../NotificationBell';
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
import i18n from 'dashboard/i18n';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(Vuex);
|
||||
localVue.use(VueI18n);
|
||||
|
||||
const i18nConfig = new VueI18n({
|
||||
locale: 'en',
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
describe('notificationBell', () => {
|
||||
const accountId = 1;
|
||||
const notificationMetadata = { unreadCount: 19 };
|
||||
let store = null;
|
||||
let actions = null;
|
||||
let modules = null;
|
||||
|
||||
beforeEach(() => {
|
||||
actions = {
|
||||
showNotification: jest.fn(),
|
||||
};
|
||||
modules = {
|
||||
auth: {
|
||||
getters: {
|
||||
getCurrentAccountId: () => accountId,
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
getters: {
|
||||
'notifications/getMeta': () => notificationMetadata,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
store = new Vuex.Store({
|
||||
actions,
|
||||
modules,
|
||||
});
|
||||
});
|
||||
|
||||
it('it should return unread count 19 ', () => {
|
||||
const notificationBell = shallowMount(NotificationBell, {
|
||||
store,
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
});
|
||||
const statusViewTitle = notificationBell.find('.unread-badge');
|
||||
expect(statusViewTitle.text()).toBe('19');
|
||||
});
|
||||
|
||||
it('it should return unread count 99+ ', async () => {
|
||||
notificationMetadata.unreadCount = 101;
|
||||
const notificationBell = shallowMount(NotificationBell, {
|
||||
store,
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
});
|
||||
const statusViewTitle = notificationBell.find('.unread-badge');
|
||||
expect(statusViewTitle.text()).toBe('99+');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
export default {
|
||||
props: {
|
||||
title: {
|
||||
@@ -52,7 +52,11 @@ export default {
|
||||
},
|
||||
labelStyle() {
|
||||
if (this.bgColor) {
|
||||
return { background: this.bgColor, color: this.textColor };
|
||||
return {
|
||||
background: this.bgColor,
|
||||
color: this.textColor,
|
||||
border: `1px solid ${this.bgColor}`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
<template>
|
||||
<button
|
||||
class="button"
|
||||
:class="[
|
||||
variant,
|
||||
size,
|
||||
colorScheme,
|
||||
classNames,
|
||||
isDisabled ? 'disabled' : '',
|
||||
]"
|
||||
:class="buttonClasses"
|
||||
:disabled="isDisabled || isLoading"
|
||||
@click="handleClick"
|
||||
>
|
||||
<spinner v-if="isLoading" size="small" />
|
||||
<i v-else-if="icon" class="icon" :class="icon"></i>
|
||||
<emoji-or-icon
|
||||
v-else-if="icon || emoji"
|
||||
class="icon"
|
||||
:emoji="emoji"
|
||||
:icon="icon"
|
||||
/>
|
||||
<span v-if="$slots.default" class="button__content"><slot></slot></span>
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
|
||||
|
||||
export default {
|
||||
name: 'WootButton',
|
||||
components: { Spinner },
|
||||
components: { EmojiOrIcon, Spinner },
|
||||
props: {
|
||||
variant: {
|
||||
type: String,
|
||||
@@ -35,12 +35,16 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emoji: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
colorScheme: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
classNames: {
|
||||
type: String,
|
||||
type: [String, Object],
|
||||
default: '',
|
||||
},
|
||||
isDisabled: {
|
||||
@@ -51,6 +55,34 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
variantClasses() {
|
||||
if (this.variant.includes('link')) {
|
||||
return `clear ${this.variant}`;
|
||||
}
|
||||
return this.variant;
|
||||
},
|
||||
hasOnlyIconClasses() {
|
||||
const hasEmojiOrIcon = this.emoji || this.icon;
|
||||
if (!this.$slots.default && hasEmojiOrIcon) return 'button--only-icon';
|
||||
return '';
|
||||
},
|
||||
buttonClasses() {
|
||||
return [
|
||||
this.variantClasses,
|
||||
this.hasOnlyIconClasses,
|
||||
this.size,
|
||||
this.colorScheme,
|
||||
this.classNames,
|
||||
this.isDisabled ? 'disabled' : '',
|
||||
this.isExpanded ? 'expanded' : '',
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick(evt) {
|
||||
@@ -59,20 +91,3 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.link {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
padding: 0 var(--space-small);
|
||||
}
|
||||
.icon + .button__content {
|
||||
padding-left: var(--space-small);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
<template>
|
||||
<button :type="type" class="button nice" :class="variant" @click="onClick">
|
||||
<i
|
||||
v-if="!isLoading && icon"
|
||||
class="icon"
|
||||
:class="buttonIconClass + ' ' + icon"
|
||||
/>
|
||||
<spinner v-if="isLoading" />
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
buttonIconClass: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'button',
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onClick(e) {
|
||||
this.$emit('click', e);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
font-size: var(--font-size-large) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -17,9 +17,13 @@
|
||||
src="~dashboard/assets/images/channels/telegram.png"
|
||||
/>
|
||||
<img
|
||||
v-if="channel.key === 'api'"
|
||||
v-if="channel.key === 'api' && !channel.thumbnail"
|
||||
src="~dashboard/assets/images/channels/api.png"
|
||||
/>
|
||||
<img
|
||||
v-if="channel.key === 'api' && channel.thumbnail"
|
||||
:src="channel.thumbnail"
|
||||
/>
|
||||
<img
|
||||
v-if="channel.key === 'email'"
|
||||
src="~dashboard/assets/images/channels/email.png"
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
<template>
|
||||
<div class="inbox-item" >
|
||||
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image"/>
|
||||
<div class="inbox-item">
|
||||
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image" />
|
||||
<div class="item--details columns">
|
||||
<h4 class="item--name">{{ inbox.label }}</h4>
|
||||
<p class="item--sub">Facebook</p>
|
||||
<h4 class="item--name">
|
||||
{{ inbox.label }}
|
||||
</h4>
|
||||
<p class="item--sub">
|
||||
Facebook
|
||||
</p>
|
||||
</div>
|
||||
<!-- <span class="ion-chevron-right arrow"></span> -->
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
/* eslint no-console: 0 */
|
||||
/* global bus */
|
||||
// import WootSwitch from '../ui/Switch';
|
||||
|
||||
export default {
|
||||
props: ['inbox'],
|
||||
created() {
|
||||
},
|
||||
created() {},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import LabelSelector from './LabelSelector';
|
||||
|
||||
export default {
|
||||
title: 'Components/Label/Contact Label',
|
||||
component: LabelSelector,
|
||||
argTypes: {
|
||||
contactId: {
|
||||
control: {
|
||||
type: 'text ,number',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { LabelSelector },
|
||||
template:
|
||||
'<label-selector v-bind="$props" @add="onAdd" @remove="onRemove"></label-selector>',
|
||||
});
|
||||
|
||||
export const ContactLabel = Template.bind({});
|
||||
ContactLabel.args = {
|
||||
onAdd: action('Added'),
|
||||
onRemove: action('Removed'),
|
||||
allLabels: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'sales',
|
||||
description: '',
|
||||
color: '#0a5dd1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'refund',
|
||||
description: '',
|
||||
color: '#8442f5',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'testing',
|
||||
description: '',
|
||||
color: '#f542f5',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'scheduled',
|
||||
description: '',
|
||||
color: '#42d1f5',
|
||||
},
|
||||
],
|
||||
savedLabels: [
|
||||
{
|
||||
id: '2',
|
||||
title: 'refund',
|
||||
description: '',
|
||||
color: '#8442f5',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'scheduled',
|
||||
description: '',
|
||||
color: '#42d1f5',
|
||||
},
|
||||
],
|
||||
};
|
||||
116
app/javascript/dashboard/components/widgets/LabelSelector.vue
Normal file
116
app/javascript/dashboard/components/widgets/LabelSelector.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<h6 class="text-block-title">
|
||||
<i class="title-icon ion-pricetags" />
|
||||
{{ $t('CONTACT_PANEL.LABELS.CONTACT.TITLE') }}
|
||||
</h6>
|
||||
<div v-on-clickaway="closeDropdownLabel" class="label-wrap">
|
||||
<add-label @add="toggleLabels" />
|
||||
<woot-label
|
||||
v-for="label in savedLabels"
|
||||
:key="label.id"
|
||||
:title="label.title"
|
||||
:description="label.description"
|
||||
:show-close="true"
|
||||
:bg-color="label.color"
|
||||
@click="removeItem"
|
||||
/>
|
||||
<div class="dropdown-wrap">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
|
||||
class="dropdown-pane"
|
||||
>
|
||||
<label-dropdown
|
||||
v-if="showSearchDropdownLabel"
|
||||
:account-labels="allLabels"
|
||||
:selected-labels="selectedLabels"
|
||||
@add="addItem"
|
||||
@remove="removeItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddLabel,
|
||||
LabelDropdown,
|
||||
},
|
||||
|
||||
mixins: [clickaway],
|
||||
|
||||
props: {
|
||||
allLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
savedLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showSearchDropdownLabel: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedLabels() {
|
||||
return this.savedLabels.map(label => label.title);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addItem(label) {
|
||||
this.$emit('add', label);
|
||||
},
|
||||
|
||||
removeItem(label) {
|
||||
this.$emit('remove', label);
|
||||
},
|
||||
|
||||
toggleLabels() {
|
||||
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
|
||||
},
|
||||
|
||||
closeDropdownLabel() {
|
||||
this.showSearchDropdownLabel = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-icon {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
.label-wrap {
|
||||
position: relative;
|
||||
margin-left: var(--space-two);
|
||||
line-height: var(--space-medium);
|
||||
|
||||
.dropdown-wrap {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
margin-right: var(--space-medium);
|
||||
top: var(--space-medium);
|
||||
width: 100%;
|
||||
left: -1px;
|
||||
|
||||
.dropdown-pane {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
import SettingIntroBanner from './SettingIntroBanner';
|
||||
|
||||
export default {
|
||||
title: 'Components/Settings/Banner',
|
||||
component: SettingIntroBanner,
|
||||
argTypes: {
|
||||
headerTitle: {
|
||||
defaultValue: 'Acme Support',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
headerContent: {
|
||||
defaultValue:
|
||||
'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.',
|
||||
control: {
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
props: Object.keys(argTypes),
|
||||
components: { SettingIntroBanner },
|
||||
template: '<setting-intro-banner v-bind="$props" ></setting-intro-banner>',
|
||||
});
|
||||
|
||||
export const Banner = Template.bind({});
|
||||
Banner.args = {};
|
||||
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="column page-top-banner">
|
||||
<h2 class="page-sub-title">
|
||||
{{ headerTitle }}
|
||||
</h2>
|
||||
<p v-if="headerContent" class="small-12 column">
|
||||
{{ headerContent }}
|
||||
</p>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
headerTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
headerContent: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page-top-banner {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-background-light);
|
||||
padding: var(--space-normal) var(--space-large) 0;
|
||||
}
|
||||
</style>
|
||||
@@ -14,6 +14,8 @@
|
||||
>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class-names="goto-first"
|
||||
:is-disabled="hasFirstPage"
|
||||
@click="onFirstPage"
|
||||
@@ -23,16 +25,25 @@
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasPrevPage"
|
||||
@click="onPrevPage"
|
||||
>
|
||||
<i class="ion-chevron-left" />
|
||||
</woot-button>
|
||||
<woot-button @click.prevent>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
@click.prevent
|
||||
>
|
||||
{{ currentPage }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
:is-disabled="hasNextPage"
|
||||
@click="onNextPage"
|
||||
>
|
||||
@@ -40,6 +51,8 @@
|
||||
</woot-button>
|
||||
<woot-button
|
||||
size="small"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
class-names="goto-last"
|
||||
:is-disabled="hasLastPage"
|
||||
@click="onLastPage"
|
||||
@@ -147,52 +160,11 @@ export default {
|
||||
.page-meta {
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
.pagination-button-group {
|
||||
margin: 0;
|
||||
|
||||
.button {
|
||||
background: transparent;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-body);
|
||||
margin-bottom: 0;
|
||||
margin-left: -2px;
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-small) var(--space-normal);
|
||||
border-radius: 0;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background: var(--s-200);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: var(--space-smaller);
|
||||
border-bottom-left-radius: var(--space-smaller);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-top-right-radius: var(--space-smaller);
|
||||
border-bottom-right-radius: var(--space-smaller);
|
||||
}
|
||||
|
||||
&.small {
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: var(--s-200);
|
||||
border-color: var(--s-200);
|
||||
color: var(--b-900);
|
||||
}
|
||||
|
||||
&.goto-first,
|
||||
&.goto-last {
|
||||
i:last-child {
|
||||
margin-left: var(--space-minus-smaller);
|
||||
}
|
||||
}
|
||||
.goto-first,
|
||||
.goto-last {
|
||||
i:last-child {
|
||||
margin-left: var(--space-minus-smaller);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,8 @@ describe(`when there are NO errors loading the thumbnail`, () => {
|
||||
},
|
||||
});
|
||||
expect(wrapper.find('#image').exists()).toBe(true);
|
||||
expect(wrapper.contains(Avatar)).toBe(false);
|
||||
const avatarComponent = wrapper.findComponent(Avatar);
|
||||
expect(avatarComponent.exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +32,9 @@ describe(`when there ARE errors loading the thumbnail`, () => {
|
||||
};
|
||||
},
|
||||
});
|
||||
expect(wrapper.contains('#image')).toBe(false);
|
||||
expect(wrapper.contains(Avatar)).toBe(true);
|
||||
expect(wrapper.find('#image').exists()).toBe(false);
|
||||
const avatarComponent = wrapper.findComponent(Avatar);
|
||||
expect(avatarComponent.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,30 +5,40 @@
|
||||
:search-key="mentionSearchKey"
|
||||
@click="insertMentionNode"
|
||||
/>
|
||||
<canned-response
|
||||
v-if="showCannedMenu"
|
||||
:search-key="cannedSearchTerm"
|
||||
@click="insertCannedResponse"
|
||||
/>
|
||||
<div ref="editor"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
|
||||
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
|
||||
import {
|
||||
addMentionsToMarkdownSerializer,
|
||||
addMentionsToMarkdownParser,
|
||||
schemaWithMentions,
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/schema';
|
||||
|
||||
import {
|
||||
suggestionsPlugin,
|
||||
triggerCharacters,
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import { EditorState } from 'prosemirror-state';
|
||||
import { defaultMarkdownParser } from 'prosemirror-markdown';
|
||||
import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
|
||||
|
||||
import TagAgents from '../conversation/TagAgents';
|
||||
import CannedResponse from '../conversation/CannedResponse';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
|
||||
import '@chatwoot/prosemirror-schema/src/woot-editor.css';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
return EditorState.create({
|
||||
doc: addMentionsToMarkdownParser(defaultMarkdownParser).parse(content),
|
||||
@@ -42,7 +52,7 @@ const createState = (content, placeholder, plugins = []) => {
|
||||
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents },
|
||||
components: { TagAgents, CannedResponse },
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
@@ -52,7 +62,9 @@ export default {
|
||||
return {
|
||||
lastValue: null,
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
mentionSearchKey: '',
|
||||
cannedSearchTerm: '',
|
||||
editorView: null,
|
||||
range: null,
|
||||
};
|
||||
@@ -85,6 +97,35 @@ export default {
|
||||
return event.keyCode === 13 && this.showUserMentions;
|
||||
},
|
||||
}),
|
||||
suggestionsPlugin({
|
||||
matcher: triggerCharacters('/'),
|
||||
suggestionClass: '',
|
||||
onEnter: args => {
|
||||
if (this.isPrivate) {
|
||||
return false;
|
||||
}
|
||||
this.showCannedMenu = true;
|
||||
this.range = args.range;
|
||||
this.editorView = args.view;
|
||||
return false;
|
||||
},
|
||||
onChange: args => {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.cannedSearchTerm = args.text.replace('/', '');
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
this.cannedSearchTerm = '';
|
||||
this.showCannedMenu = false;
|
||||
this.editorView = null;
|
||||
return false;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
return event.keyCode === 13 && this.showCannedMenu;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
},
|
||||
@@ -92,9 +133,14 @@ export default {
|
||||
showUserMentions(updatedValue) {
|
||||
this.$emit('toggle-user-mention', this.isPrivate && updatedValue);
|
||||
},
|
||||
value(newValue) {
|
||||
showCannedMenu(updatedValue) {
|
||||
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
|
||||
},
|
||||
value(newValue = '') {
|
||||
if (newValue !== this.lastValue) {
|
||||
this.state = createState(newValue, this.placeholder, this.plugins);
|
||||
const { tr } = this.state;
|
||||
tr.insertText(newValue, 0, tr.doc.content.size);
|
||||
this.state = this.view.state.apply(tr);
|
||||
this.view.updateState(this.state);
|
||||
}
|
||||
},
|
||||
@@ -140,6 +186,21 @@ export default {
|
||||
this.state = this.view.state.apply(tr);
|
||||
return this.emitOnChange();
|
||||
},
|
||||
|
||||
insertCannedResponse(cannedItem) {
|
||||
if (!this.view) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tr = this.view.state.tr.insertText(
|
||||
cannedItem,
|
||||
this.range.from,
|
||||
this.range.to
|
||||
);
|
||||
this.state = this.view.state.apply(tr);
|
||||
return this.emitOnChange();
|
||||
},
|
||||
|
||||
emitOnChange() {
|
||||
this.view.updateState(this.state);
|
||||
this.lastValue = addMentionsToMarkdownSerializer(
|
||||
@@ -205,10 +266,9 @@ export default {
|
||||
.is-private {
|
||||
.prosemirror-mention-node {
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--s-300);
|
||||
border-radius: var(--border-radius-small);
|
||||
padding: 1px 4px;
|
||||
color: var(--white);
|
||||
background: var(--s-50);
|
||||
color: var(--s-900);
|
||||
padding: 0 var(--space-smaller);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,42 @@
|
||||
<template>
|
||||
<div class="bottom-box" :class="wrapClass">
|
||||
<div class="left-wrap">
|
||||
<button
|
||||
class="button clear button--emoji"
|
||||
<woot-button
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
|
||||
icon="ion-happy-outline"
|
||||
emoji="😊"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
|
||||
<file-upload
|
||||
:size="4096 * 4096"
|
||||
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<emoji-or-icon icon="ion-happy-outline" emoji="😊" />
|
||||
</button>
|
||||
<button
|
||||
v-if="showAttachButton"
|
||||
class="button clear button--emoji button--upload"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
>
|
||||
<file-upload
|
||||
:size="4096 * 4096"
|
||||
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv"
|
||||
@input-file="onFileUpload"
|
||||
>
|
||||
<emoji-or-icon icon="ion-android-attach" emoji="📎" />
|
||||
</file-upload>
|
||||
</button>
|
||||
<button
|
||||
<woot-button
|
||||
v-if="showAttachButton"
|
||||
class-names="button--upload"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
icon="ion-android-attach"
|
||||
emoji="📎"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
/>
|
||||
</file-upload>
|
||||
<woot-button
|
||||
v-if="enableRichEditor && !isOnPrivateNote"
|
||||
class="button clear button--emoji"
|
||||
icon="ion-quote"
|
||||
emoji="🖊️"
|
||||
color-scheme="secondary"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
:title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
|
||||
@click="toggleFormatMode"
|
||||
>
|
||||
<emoji-or-icon icon="ion-quote" emoji="🖊️" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
<div class="right-wrap">
|
||||
<div v-if="isFormatMode" class="enter-to-send--checkbox">
|
||||
@@ -42,25 +50,25 @@
|
||||
{{ $t('CONVERSATION.REPLYBOX.ENTER_TO_SEND') }}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="button nice primary button--send"
|
||||
:class="buttonClass"
|
||||
<woot-button
|
||||
size="small"
|
||||
:class-names="buttonClass"
|
||||
:is-disabled="isSendDisabled"
|
||||
@click="onSend"
|
||||
>
|
||||
{{ sendButtonText }}
|
||||
</button>
|
||||
</woot-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
|
||||
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
components: { EmojiOrIcon, FileUpload },
|
||||
components: { FileUpload },
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
@@ -126,8 +134,7 @@ export default {
|
||||
},
|
||||
buttonClass() {
|
||||
return {
|
||||
'button--note': this.isNote,
|
||||
'button--disabled': this.isSendDisabled,
|
||||
warning: this.isNote,
|
||||
};
|
||||
},
|
||||
showAttachButton() {
|
||||
@@ -146,9 +153,6 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '~widget/assets/scss/variables.scss';
|
||||
@import '~widget/assets/scss/mixins.scss';
|
||||
|
||||
.bottom-box {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -159,39 +163,8 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
&.button--emoji {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: white;
|
||||
}
|
||||
|
||||
&.button--note {
|
||||
background: var(--y-800);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
background: var(--y-700);
|
||||
}
|
||||
}
|
||||
|
||||
&.button--disabled {
|
||||
background: var(--b-100);
|
||||
color: var(--b-400);
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
background: var(--b-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-box.is-note-mode {
|
||||
.button--emoji {
|
||||
background: white;
|
||||
}
|
||||
.left-wrap .button {
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.left-wrap {
|
||||
@@ -199,15 +172,6 @@ export default {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.button--reply {
|
||||
border-right: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.icon--font {
|
||||
color: var(--s-600);
|
||||
font-size: var(--font-size-default);
|
||||
}
|
||||
|
||||
.right-wrap {
|
||||
display: flex;
|
||||
|
||||
@@ -225,4 +189,13 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep .file-uploads {
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:hover .button {
|
||||
background: var(--s-100);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user