Merge branch 'develop' of https://github.com/chatwoot/chatwoot into ui/agent-dropdown

This commit is contained in:
Sivin Varghese 2021-06-11 16:30:43 +05:30
commit 4f79c13977
272 changed files with 6878 additions and 2262 deletions

View file

@ -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

View file

@ -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'

View file

@ -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'

View file

@ -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.4)
nokogiri (1.11.6)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
oauth (0.5.6)
@ -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

View file

@ -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

View file

@ -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!

View file

@ -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)

View file

@ -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

View file

@ -0,0 +1,5 @@
class AndroidAppController < ApplicationController
def assetlinks
render layout: false
end
end

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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
[]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -1,7 +0,0 @@
class Api::V1::AgentBotsController < Api::BaseController
skip_before_action :authenticate_user!
def index
render json: AgentBot.all
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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')

View file

@ -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]
)

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);
});
});
},

View file

@ -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();

View file

@ -5,6 +5,7 @@ function apiSpecHelper() {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
window.axios = this.axiosMock;
});

View 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'
);
});
});
});

View 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%;
}
}

View file

@ -11,13 +11,13 @@
@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';
@ -46,5 +46,4 @@
@import 'plugins/multiselect';
@import 'plugins/dropdown';
@import '@chatwoot/prosemirror-schema/src/woot-editor.css';
@import '~shared/assets/stylesheets/ionicons';

View file

@ -31,8 +31,9 @@
}
img {
width: 50%;
@include margin($space-normal auto);
flex: 1;
width: 50%;
}
.channel__title{

View file

@ -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 {

View file

@ -1,31 +1,24 @@
$default-button-height: 4.0rem;
.button {
align-items: center;
display: inline-flex;
height: 4.0rem;
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);
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);
}
}
.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);
}
@ -48,23 +41,23 @@
// Smooth style
&.smooth {
@include button-style(var(--w-100), var(--w-50), var(--w-700));
@include button-style(var(--w-50), var(--w-100), var(--w-700));
&.secondary {
@include button-style(var(--s-100), var(--s-50), var(--s-700));
@include button-style(var(--s-50), var(--s-100), var(--s-700));
}
&.success {
@include button-style(var(--g-100), var(--g-50), var(--g-700));
@include button-style(var(--g-50), var(--g-100), var(--g-700));
}
&.alert {
@include button-style(var(--r-100), var(--r-50), var(--r-700));
@include button-style(var(--r-50), var(--r-100), var(--r-700));
}
&.warning {
@include button-style(var(--y-200), var(--y-100), var(--y-900));
@include button-style(var(--y-100), var(--y-200), var(--y-900));
}
}
@ -81,6 +74,25 @@
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;

View file

@ -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);
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -264,10 +264,6 @@ export default {
.modal-container {
width: 40rem;
}
.page-top-bar {
padding-bottom: $space-two;
}
}
.account-selector {

View file

@ -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 {};
},

View file

@ -6,16 +6,22 @@
@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,
@ -29,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: {
@ -57,9 +67,15 @@ export default {
}
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,

View file

@ -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>

View file

@ -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"

View file

@ -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>

View file

@ -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 = {};

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -152,7 +152,10 @@ export default {
return !messageType ? 'left' : 'right';
},
readableTime() {
return this.messageStamp(this.data.created_at, 'LLL d, h:mm a');
return this.messageStamp(
this.contentAttributes.external_created_at || this.data.created_at,
'LLL d, h:mm a'
);
},
isBubble() {
return [0, 1, 3].includes(this.data.message_type);
@ -197,11 +200,16 @@ export default {
'is-private': this.data.private,
'is-image': this.hasImageAttachment,
'is-text': this.hasText,
'is-from-bot': this.isSentByBot,
};
},
isPending() {
return this.data.status === MESSAGE_STATUS.PROGRESS;
},
isSentByBot() {
if (this.isPending) return false;
return !this.sender.type || this.sender.type === 'agent_bot';
},
},
};
</script>
@ -250,6 +258,13 @@ export default {
color: var(--color-body);
text-decoration: underline;
}
&.is-from-bot {
background: var(--v-400);
.message-text--metadata .time {
color: var(--v-50);
}
}
}
&.is-pending {

View file

@ -42,6 +42,7 @@
@focus="onFocus"
@blur="onBlur"
@toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
/>
</div>
<div v-if="hasAttachments" class="attachment-preview-box">
@ -249,7 +250,8 @@ export default {
}
},
message(updatedMessage) {
this.hasSlashCommand = updatedMessage[0] === '/';
this.hasSlashCommand =
updatedMessage[0] === '/' && !this.showRichContentEditor;
const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
@ -271,6 +273,9 @@ export default {
toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState;
},
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
handleKeyEvents(e) {
if (isEscape(e)) {
this.hideEmojiPicker();
@ -279,7 +284,8 @@ export default {
const hasSendOnEnterEnabled =
(this.showRichContentEditor &&
this.enterToSendEnabled &&
!this.hasUserMention) ||
!this.hasUserMention &&
!this.showCannedMenu) ||
!this.showRichContentEditor;
const shouldSendMessage =
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused;

View file

@ -0,0 +1,86 @@
<template>
<modal :show.sync="show" :on-close="closeModal">
<woot-modal-header :header-title="title" :header-content="message" />
<form @submit.prevent="onConfirm">
<woot-input
v-model="value"
type="text"
:class="{ error: $v.value.$error }"
:placeholder="confirmPlaceHolderText"
@blur="$v.value.$touch"
/>
<div class="button-wrapper">
<woot-button color-scheme="alert" :is-disabled="$v.value.$invalid">
{{ confirmText }}
</woot-button>
<woot-button class="clear" @click.prevent="closeModal">
{{ rejectText }}
</woot-button>
</div>
</form>
</modal>
</template>
<script>
import { required } from 'vuelidate/lib/validators';
import Modal from '../../Modal';
export default {
components: {
Modal,
},
props: {
show: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
confirmText: {
type: String,
default: '',
},
rejectText: {
type: String,
default: '',
},
confirmValue: {
type: String,
default: '',
},
confirmPlaceHolderText: {
type: String,
default: '',
},
},
data() {
return {
value: '',
};
},
validations: {
value: {
required,
isEqual(value) {
return value === this.confirmValue;
},
},
},
methods: {
closeModal() {
this.value = '';
this.$emit('on-close');
},
onConfirm() {
this.$emit('on-confirm');
},
},
};
</script>

View file

@ -1,8 +1,4 @@
export default {
APP_BASE_URL: '/',
get apiURL() {
return `${this.APP_BASE_URL}/`;
},
GRAVATAR_URL: 'https://www.gravatar.com/avatar/',
ASSIGNEE_TYPE: {
ME: 'me',
@ -15,3 +11,4 @@ export default {
BOT: 'bot',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';

View file

@ -1,14 +1,11 @@
/* eslint no-console: 0 */
import constants from '../constants';
import Auth from '../api/auth';
const parseErrorCode = error => {
return Promise.reject(error);
};
const parseErrorCode = error => Promise.reject(error);
export default axios => {
const wootApi = axios.create();
wootApi.defaults.baseURL = constants.apiURL;
const { apiHost = '' } = window.chatwootConfig || {};
const wootApi = axios.create({ baseURL: `${apiHost}/` });
// Add Auth Headers to requests if logged in
if (Auth.isLoggedIn()) {
const {

View file

@ -4,7 +4,8 @@ import { newMessageNotification } from 'shared/helpers/AudioNotificationHelper';
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
super(app, pubsubToken);
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
this.events = {
'message.created': this.onMessageCreated,

View file

@ -13,6 +13,18 @@ export default () => {
}
};
export const isEmptyObject = obj =>
Object.keys(obj).length === 0 && obj.constructor === Object;
export const isJSONValid = value => {
try {
JSON.parse(value);
} catch (e) {
return false;
}
return true;
};
export const getTypingUsersText = (users = []) => {
const count = users.length;
if (count === 1) {

View file

@ -74,6 +74,9 @@ export const getSidebarItems = accountId => ({
'settings_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'general_settings_index',
'settings_teams_list',
@ -136,6 +139,13 @@ export const getSidebarItems = accountId => ({
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
},
settings_applications: {
icon: 'ion-asterisk',
label: 'APPLICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
},
general_settings_index: {
icon: 'ion-gear-a',
label: 'ACCOUNT_SETTINGS',

View file

@ -155,6 +155,16 @@
"VIEW_DETAILS": "View details"
}
},
"REMINDER": {
"ADD_BUTTON": {
"BUTTON": "Add",
"TITLE": "Shift + Enter to create a task"
},
"FOOTER": {
"DUE_DATE": "Due date",
"LABEL_TITLE": "Set type"
}
},
"NOTES": {
"HEADER": {
"TITLE": "Notes"
@ -168,6 +178,16 @@
"BUTTON": "View all notes"
}
},
"EVENTS": {
"HEADER": {
"TITLE": "Activities"
},
"BUTTON": {
"PILL_BUTTON_NOTES": "notes",
"PILL_BUTTON_EVENTS": "events",
"PILL_BUTTON_CONVO": "conversations"
}
},
"CUSTOM_ATTRIBUTES": {
"TITLE": "Custom Attributes",
"BUTTON": "Add custom attribute",

View file

@ -110,6 +110,7 @@
"CONVERSATION_SIDEBAR": {
"DETAILS_TITLE": "Conversations Details",
"ASSIGNEE_LABEL": "Assigned Agent",
"SELF_ASSIGN": "Assign to me",
"TEAM_LABEL": "Assigned Team",
"SELECT": {
"PLACEHOLDER": "None"

View file

@ -28,6 +28,14 @@
}
],
"ADD": {
"CHANNEL_NAME": {
"LABEL": "Inbox Name",
"PLACEHOLDER": "Enter your inbox name (eg: Acme Inc)"
},
"WEBSITE_NAME": {
"LABEL": "Website Name",
"PLACEHOLDER": "Enter your website name (eg: Acme Inc)"
},
"FB": {
"HELP": "PS: By signing in, we only get access to your Page's messages. Your private messages can never be accessed by Chatwoot.",
"CHOOSE_PAGE": "Choose Page",
@ -48,10 +56,6 @@
"CHANNEL_AVATAR": {
"LABEL": "Channel Avatar"
},
"CHANNEL_NAME": {
"LABEL": "Website Name",
"PLACEHOLDER": "Enter your website name (eg: Acme Inc)"
},
"CHANNEL_DOMAIN": {
"LABEL": "Website Domain",
"PLACEHOLDER": "Enter your website domain (eg: acme.com)"
@ -208,6 +212,10 @@
"AUTO_ASSIGNMENT": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"EMAIL_COLLECT_BOX": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
}
},
"DELETE": {
@ -215,6 +223,7 @@
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ",
"PLACE_HOLDER": "Please type {inboxName} to confirm",
"YES": "Yes, Delete ",
"NO": "No, Keep "
},
@ -243,6 +252,8 @@
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"UPDATE": "Update",
"ENABLE_EMAIL_COLLECT_BOX": "Enable email collect box",
"ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT": "Enable or disable email collect box on new conversation",
"AUTO_ASSIGNMENT": "Enable auto assignment",
"INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your inbox settings",

View file

@ -15,6 +15,7 @@ import { default as _setNewPassword } from './setNewPassword.json';
import { default as _settings } from './settings.json';
import { default as _signup } from './signup.json';
import { default as _teamsSettings } from './teamsSettings.json';
import { default as _integrationApps } from './integrationApps.json';
export default {
..._agentMgmt,
@ -34,4 +35,5 @@ export default {
..._settings,
..._signup,
..._teamsSettings,
..._integrationApps,
};

View file

@ -0,0 +1,62 @@
{
"INTEGRATION_APPS": {
"FETCHING": "Fetching Integrations",
"NO_HOOK_CONFIGURED": "There are no %{integrationId} integrations configured in this account.",
"HEADER": "Applications",
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"CONFIGURE": "Configure",
"ADD_BUTTON": "Add a new hook",
"DELETE": {
"TITLE": {
"INBOX": "Confirm deletion",
"ACCOUNT": "Disconnect"
},
"MESSAGE": {
"INBOX": "Are you sure to delete?",
"ACCOUNT": "Are you sure to disconnect?"
},
"CONFIRM_BUTTON_TEXT": {
"INBOX": "Yes, Delete",
"ACCOUNT": "Yes, Disconnect"
},
"CANCEL_BUTTON_TEXT": "Cancel",
"API": {
"SUCCESS_MESSAGE": "Hook deleted successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"LIST": {
"FETCHING": "Fetching integration hooks",
"INBOX": "Inbox",
"DELETE": {
"BUTTON_TEXT": "Delete"
}
},
"ADD": {
"FORM": {
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox"
},
"SUBMIT": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Integration hook added successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"CONNECT": {
"BUTTON_TEXT": "Connect"
},
"DISCONNECT": {
"BUTTON_TEXT": "Disconnect"
},
"SIDEBAR_DESCRIPTION": {
"DIALOGFLOW": "Dialogflow is a natural language understanding platform that makes it easy to design and integrate a conversational user interface into your mobile app, web application, device, bot, interactive voice response system, and so on. <br /> <br /> Dialogflow integration with %{installationName} allows you to configure a Dialogflow bot with your inboxes which lets the bot handle the queries initially and hand them over to an agent when needed. Dialogflow can be used to qualifying the leads, reduce the workload of agents by providing frequently asked questions etc. <br /> <br /> To add Dialogflow, you need to create a Service Account in your Google project console and share the credentials. Please refer to the Dialogflow docs for more information."
}
}
}

View file

@ -12,10 +12,25 @@
"LIST": {
"404": "There are no webhooks configured for this account.",
"TITLE": "Manage webhooks",
"TABLE_HEADER": [
"Webhook endpoint",
"Actions"
]
"TABLE_HEADER": ["Webhook endpoint", "Actions"]
},
"EDIT": {
"BUTTON_TEXT": "Edit",
"TITLE": "Edit webhook",
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"FORM": {
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"SUBMIT": "Edit webhook"
},
"API": {
"SUCCESS_MESSAGE": "Webhook URL updated successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"ADD": {
"CANCEL": "Cancel",

View file

@ -9,17 +9,15 @@
"404": "There are no labels available in this account.",
"TITLE": "Manage labels",
"DESC": "Labels let you group the conversations together.",
"TABLE_HEADER": [
"Name",
"Description",
"Color"
]
"TABLE_HEADER": ["Name", "Description", "Color"]
},
"FORM": {
"NAME": {
"LABEL": "Label Name",
"PLACEHOLDER": "Label name",
"ERROR": "Label Name is required"
"REQUIRED_ERROR": "Label name is required",
"MINIMUM_LENGTH_ERROR": "Minimum length 2 is required",
"VALID_ERROR": "Only Alphabets, Numbers, Hyphen and Underscore are allowed"
},
"DESCRIPTION": {
"LABEL": "Description",

View file

@ -74,6 +74,11 @@
"ERROR": "Please enter a valid email address",
"PLACEHOLDER": "Please enter your email address, this would be displayed in conversations"
},
"CURRENT_PASSWORD": {
"LABEL": "Current password",
"ERROR": "Please enter the current password",
"PLACEHOLDER": "Please enter the current password"
},
"PASSWORD": {
"LABEL": "Password",
"ERROR": "Please enter a password of length 6 or more",
@ -128,6 +133,7 @@
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",
"ACCOUNT_SETTINGS": "Account Settings",
"APPLICATIONS": "Applications",
"LABELS": "Labels",
"TEAMS": "Teams"
},

View file

@ -17,7 +17,8 @@
"TITLE": "Add agents to team - %{teamName}",
"DESC": "Add Agents to your newly created team. This lets you collaborate as a team on conversations, get notified on new events in the same conversation."
},
"WIZARD": [{
"WIZARD": [
{
"title": "Create",
"route": "settings_teams_new",
"body": "Create a new team of agents."
@ -45,7 +46,8 @@
"TITLE": "Add agents to team - %{teamName}",
"DESC": "Add Agents to your newly created team. All the added agents will be notified when a conversation is assigned to this team."
},
"WIZARD": [{
"WIZARD": [
{
"title": "Team details",
"route": "settings_teams_edit",
"body": "Change name, description and other details."
@ -97,6 +99,7 @@
},
"CONFIRM": {
"TITLE": "Are you sure want to delete - %{teamName}",
"PLACE_HOLDER": "Please type {teamName} to confirm",
"MESSAGE": "Deleting the team will remove the team assignment from the conversations assigned to this team.",
"YES": "Delete ",
"NO": "Cancel"

View file

@ -2,31 +2,33 @@
<loading-state :message="$t('CONFIRM_EMAIL')"></loading-state>
</template>
<script>
/* eslint-disable */
import LoadingState from '../../components/widgets/LoadingState';
import Auth from '../../api/auth';
import { DEFAULT_REDIRECT_URL } from '../../constants';
export default {
props: {
confirmationToken: String,
redirectUrl: String,
config: String,
},
components: {
LoadingState,
},
props: {
confirmationToken: {
type: String,
default: '',
},
},
mounted() {
this.confirmToken();
},
methods: {
confirmToken() {
Auth.verifyPasswordToken({
confirmationToken: this.confirmationToken
}).then(res => {
window.location = res.data.redirect_url;
}).catch(res => {
window.location = '/';
});
}
}
}
async confirmToken() {
try {
await Auth.verifyPasswordToken({
confirmationToken: this.confirmationToken,
});
window.location = DEFAULT_REDIRECT_URL;
} catch (error) {
window.location = DEFAULT_REDIRECT_URL;
}
},
},
};
</script>

View file

@ -46,12 +46,11 @@
</template>
<script>
/* global bus */
import { required, minLength } from 'vuelidate/lib/validators';
import Auth from '../../api/auth';
import WootSubmitButton from '../../components/buttons/FormSubmitButton';
import { DEFAULT_REDIRECT_URL } from '../../constants';
export default {
components: {
@ -81,7 +80,7 @@ export default {
// If url opened without token
// redirect to login
if (!this.resetPasswordToken) {
window.location = '/';
window.location = DEFAULT_REDIRECT_URL;
}
},
validations: {
@ -118,11 +117,15 @@ export default {
Auth.setNewPassword(credentials)
.then(res => {
if (res.status === 200) {
window.location = '/';
window.location = DEFAULT_REDIRECT_URL;
}
})
.catch(() => {
this.showAlert(this.$t('SET_NEW_PASSWORD.API.ERROR_MESSAGE'));
.catch(error => {
let errorMessage = this.$t('SET_NEW_PASSWORD.API.ERROR_MESSAGE');
if (error?.data?.message) {
errorMessage = error.data.message;
}
this.showAlert(errorMessage);
});
},
},

View file

@ -30,7 +30,6 @@
<script>
import { required, minLength, email } from 'vuelidate/lib/validators';
import Auth from '../../api/auth';
import { frontendURL } from '../../helper/URLHelper';
export default {
data() {
@ -71,7 +70,6 @@ export default {
successMessage = res.data.message;
}
this.showAlert(successMessage);
window.location = frontendURL('login');
})
.catch(error => {
let errorMessage = this.$t('RESET_PASSWORD.API.ERROR_MESSAGE');

View file

@ -25,6 +25,17 @@
"
@blur="$v.credentials.fullName.$touch"
/>
<woot-input
v-model.trim="credentials.email"
type="email"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
:error="
$v.credentials.email.$error ? $t('REGISTER.EMAIL.ERROR') : ''
"
@blur="$v.credentials.email.$touch"
/>
<woot-input
v-model="credentials.accountName"
:class="{ error: $v.credentials.accountName.$error }"
@ -38,15 +49,31 @@
@blur="$v.credentials.accountName.$touch"
/>
<woot-input
v-model.trim="credentials.email"
type="email"
:class="{ error: $v.credentials.email.$error }"
:label="$t('REGISTER.EMAIL.LABEL')"
:placeholder="$t('REGISTER.EMAIL.PLACEHOLDER')"
v-model.trim="credentials.password"
type="password"
:class="{ error: $v.credentials.password.$error }"
:label="$t('LOGIN.PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.PASSWORD.PLACEHOLDER')"
:error="
$v.credentials.email.$error ? $t('REGISTER.EMAIL.ERROR') : ''
$v.credentials.password.$error
? $t('SET_NEW_PASSWORD.PASSWORD.ERROR')
: ''
"
@blur="$v.credentials.email.$touch"
@blur="$v.credentials.password.$touch"
/>
<woot-input
v-model.trim="credentials.confirmPassword"
type="password"
:class="{ error: $v.credentials.confirmPassword.$error }"
:label="$t('SET_NEW_PASSWORD.CONFIRM_PASSWORD.LABEL')"
:placeholder="$t('SET_NEW_PASSWORD.CONFIRM_PASSWORD.PLACEHOLDER')"
:error="
$v.credentials.confirmPassword.$error
? $t('SET_NEW_PASSWORD.CONFIRM_PASSWORD.ERROR')
: ''
"
@blur="$v.credentials.confirmPassword.$touch"
/>
<woot-submit-button
:disabled="isSignupInProgress"
@ -79,6 +106,7 @@ import Auth from '../../api/auth';
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
import { DEFAULT_REDIRECT_URL } from '../../constants';
export default {
mixins: [globalConfigMixin, alertMixin],
@ -88,6 +116,8 @@ export default {
accountName: '',
fullName: '',
email: '',
password: '',
confirmPassword: '',
},
isSignupInProgress: false,
error: '',
@ -107,6 +137,19 @@ export default {
required,
email,
},
password: {
required,
minLength: minLength(6),
},
confirmPassword: {
required,
isEqPassword(value) {
if (value !== this.credentials.password) {
return false;
}
return true;
},
},
},
},
computed: {
@ -132,7 +175,7 @@ export default {
try {
const response = await Auth.register(this.credentials);
if (response.status === 200) {
window.location = '/';
window.location = DEFAULT_REDIRECT_URL;
}
} catch (error) {
let errorMessage = this.$t('REGISTER.API.ERROR_MESSAGE');

View file

@ -0,0 +1,41 @@
import { action } from '@storybook/addon-actions';
import AddReminder from './AddReminder';
export default {
title: 'Components/Reminder/Add',
component: AddReminder,
argTypes: {
options: {
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddReminder },
template:
'<add-reminder v-bind="$props" @add="onAdd" @click="onClick"></add-reminder>',
});
export const Add = Template.bind({});
Add.args = {
onAdd: action('Added'),
onClick: action('Label'),
options: [
{
id: '12345',
name: 'calls',
},
{
id: '12346',
name: 'meeting',
},
{
id: '12347',
name: 'review',
},
],
};

View file

@ -0,0 +1,140 @@
<template>
<div class="wrap">
<div class="input-select-wrap">
<textarea
v-model="content"
class="input--reminder"
@keydown.enter.shift.exact="onAdd"
>
</textarea>
<div class="select-wrap">
<div class="select">
<div class="input-group">
<i class="ion-android-calendar input-group-label" />
<input
v-model="date"
type="text"
:placeholder="$t('REMINDER.FOOTER.DUE_DATE')"
class="input-group-field"
/>
</div>
<div class="task-wrap">
<select class="task__type" @change="optionSelected($event)">
<option value="" disabled selected>
{{ $t('REMINDER.FOOTER.LABEL_TITLE') }}
</option>
<option
v-for="option in options"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
</div>
</div>
<woot-button
size="tiny"
color-scheme="primary"
class-names="add-button"
:title="$t('REMINDER.ADD_BUTTON.TITLE')"
:is-disabled="buttonDisabled"
@click="onAdd"
>
{{ $t('REMINDER.ADD_BUTTON.BUTTON') }}
</woot-button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
options: {
type: Array,
default: () => [],
},
},
data() {
return {
content: '',
date: '',
label: '',
};
},
computed: {
buttonDisabled() {
return this.content && this.date === '';
},
},
methods: {
resetValue() {
this.content = '';
this.date = '';
},
optionSelected(event) {
this.label = event.target.value;
},
onAdd() {
const task = {
content: this.content,
date: this.date,
label: this.label,
};
this.$emit('add', task);
this.resetValue();
},
},
};
</script>
<style lang="scss" scoped>
.wrap {
display: flex;
margin-bottom: var(--space-smaller);
width: 100%;
.input-select-wrap {
padding: var(--space-small) var(--space-small);
width: 100%;
.input--reminder {
font-size: var(--font-size-mini);
margin-bottom: var(--space-small);
resize: none;
}
.select-wrap {
display: flex;
justify-content: space-between;
.select {
display: flex;
}
}
.input-group {
margin-bottom: 0;
font-size: var(--font-size-mini);
.input-group-field {
height: var(--space-medium);
font-size: var(--font-size-micro);
}
}
.task-wrap {
.task__type {
margin: 0 0 0 var(--space-smaller);
height: var(--space-medium);
width: fit-content;
padding: 0 var(--space-two) 0 var(--space-smaller);
font-size: var(--font-size-micro);
}
}
}
}
</style>

View file

@ -24,7 +24,7 @@ export default {
},
},
timeStamp: {
defaultValue: '1618046084',
defaultValue: 1618046084,
control: {
type: 'number',
},

View file

@ -6,7 +6,7 @@
<div class="footer">
<div class="meta">
<div :title="userName">
<Thumbnail :src="thumbnail" :username="userName" size="16px" />
<thumbnail :src="thumbnail" :username="userName" size="16px" />
</div>
<div class="date-wrap">
<span>{{ readableTime }}</span>
@ -14,19 +14,17 @@
</div>
<div class="actions">
<woot-button
variant="clear"
size="small"
variant="smooth"
size="tiny"
icon="ion-compose"
color-scheme="secondary"
class-names="button--emoji"
@click="onEdit"
/>
<woot-button
variant="clear"
size="small"
variant="smooth"
size="tiny"
icon="ion-trash-b"
color-scheme="secondary"
class-names="button--emoji"
@click="onDelete"
/>
</div>
@ -35,14 +33,12 @@
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import timeMixin from 'dashboard/mixins/time';
import WootButton from 'dashboard/components/ui/WootButton.vue';
export default {
components: {
Thumbnail,
WootButton,
},
mixins: [timeMixin],
@ -90,12 +86,13 @@ export default {
<style lang="scss" scoped>
.note__content {
font-size: var(--font-size-mini);
margin-bottom: var(--space-small);
margin-bottom: var(--space-smaller);
}
.footer {
display: flex;
justify-content: space-between;
align-items: flex-end;
.meta {
display: flex;
@ -111,12 +108,10 @@ export default {
.actions {
display: flex;
visibility: hidden;
}
.button--emoji {
margin-left: var(--space-small);
height: var(--space-medium);
width: var(--space-medium);
.button {
margin-left: var(--space-small);
}
}
}

View file

@ -0,0 +1,52 @@
import { action } from '@storybook/addon-actions';
import ReminderItem from './ReminderItem';
export default {
title: 'Components/Reminder/Item',
component: ReminderItem,
argTypes: {
id: {
control: {
type: 'number',
},
},
text: {
defaultValue:
'A copy and paste musical notes symbols & music symbols collection for easy access.',
control: {
type: 'text',
},
},
isCompleted: {
control: {
type: 'boolean',
},
},
date: {
defaultValue: '03/06/2020',
control: {
type: 'text',
},
},
label: {
defaultValue: 'Call',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ReminderItem },
template:
'<reminder-item v-bind="$props" @completed="onClick" @edit="onEdit" @delete="onDelete"></reminder-item>',
});
export const Item = Template.bind({});
Item.args = {
onClick: action('Marked'),
onEdit: action('Edit'),
onDelete: action('Delete'),
};

View file

@ -0,0 +1,125 @@
<template>
<div class="reminder-wrap">
<div class="status-wrap">
<input :checked="isCompleted" type="radio" @click="onClick" />
</div>
<div class="wrap">
<p class="content">
{{ text }}
</p>
<div class="footer">
<div class="meta">
<woot-label
:title="date"
description="date"
icon="ion-android-calendar"
color-scheme="secondary"
/>
<woot-label
:title="label"
description="label"
color-scheme="secondary"
/>
</div>
<div class="actions">
<woot-button
variant="smooth"
size="small"
icon="ion-compose"
color-scheme="secondary"
class="action-button"
@click="onEdit"
/>
<woot-button
variant="smooth"
size="small"
icon="ion-trash-b"
color-scheme="secondary"
class="action-button"
@click="onDelete"
/>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
id: {
type: Number,
default: 0,
},
text: {
type: String,
default: '',
},
isCompleted: {
type: Boolean,
default: false,
},
date: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
},
methods: {
onClick() {
this.$emit('completed', this.isCompleted);
},
onEdit() {
this.$emit('edit', this.id);
},
onDelete() {
this.$emit('delete', this.id);
},
},
};
</script>
<style lang="scss" scoped>
.reminder-wrap {
display: flex;
margin-bottom: var(--space-smaller);
.status-wrap {
padding: var(--space-small) var(--space-smaller);
margin-top: var(--space-smaller);
}
.wrap {
padding: var(--space-small);
.footer {
display: flex;
justify-content: space-between;
.meta {
display: flex;
}
}
}
}
.actions {
display: none;
.action-button {
margin-right: var(--space-small);
height: var(--space-medium);
width: var(--space-medium);
}
}
.reminder-wrap:hover {
.actions {
display: flex;
}
}
</style>

View file

@ -0,0 +1,22 @@
import { action } from '@storybook/addon-actions';
import SectionHeader from './SectionHeader';
export default {
title: 'Components/Events/Section',
component: SectionHeader,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { SectionHeader },
template:
'<section-header v-bind="$props" @notes="onClickNotes" @events="onClickEvents" @conversation="onClickConversation"></section-header>',
});
export const Section = Template.bind({});
Section.args = {
onClickNotes: action('notes'),
onClickEvents: action('events'),
onClickConversation: action('conversation'),
};

View file

@ -0,0 +1,71 @@
<template>
<div class="wrap">
<div class="header">
<h5 class="block-title">
{{ $t('EVENTS.HEADER.TITLE') }}
</h5>
</div>
<div class="button-wrap">
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickNotes"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_NOTES') }}
</woot-button>
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickEvents"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_EVENTS') }}
</woot-button>
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickConversation"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_CONVO') }}
</woot-button>
</div>
</div>
</template>
<script>
export default {
methods: {
onClickNotes() {
this.$emit('notes');
},
onClickEvents() {
this.$emit('events');
},
onClickConversation() {
this.$emit('conversation');
},
},
};
</script>
<style lang="scss" scoped>
.wrap {
width: 100%;
padding: var(--space-normal);
.button-wrap {
display: flex;
.pill-button {
margin-right: var(--space-small);
}
}
}
</style>

View file

@ -0,0 +1,45 @@
import { action } from '@storybook/addon-actions';
import TimelineCard from './TimelineCard';
export default {
title: 'Components/Events/Timeline',
component: TimelineCard,
argTypes: {
eventType: {
defaultValue: 'Commented',
control: {
type: 'text',
},
},
eventPath: {
defaultValue: 'chatwoot/chatwoot',
control: {
type: 'text',
},
},
eventBody: {
defaultValue:
'Commentedmany variations of passages of Lorem Ipsum available, but the majority have suffered alteration in some form, by injected humour',
control: {
type: 'text',
},
},
timeStamp: {
defaultValue: '1618046084',
control: {
type: 'number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { TimelineCard },
template: '<timeline-card v-bind="$props" @more="onClick"></timeline-card>',
});
export const Timeline = Template.bind({});
Timeline.args = {
onClick: action('more'),
};

View file

@ -0,0 +1,133 @@
<template>
<div class="timeline-card-wrap">
<div class="icon-chatbox">
<i class="ion-chatboxes" />
</div>
<div class="card-wrap">
<div class="header">
<div class="text-wrap">
<h6 class="text-block-title">
{{ eventType }}
</h6>
<span class="event-path">on {{ eventPath }}</span>
</div>
<div class="date-wrap">
<span>{{ readableTime }}</span>
</div>
</div>
<div class="comment-wrap">
<p class="comment">
{{ eventBody }}
</p>
</div>
</div>
<div class="icon-more" @click="onClick">
<i class="ion-android-more-vertical" />
</div>
</div>
</template>
<script>
import timeMixin from 'dashboard/mixins/time';
export default {
mixins: [timeMixin],
props: {
eventType: {
type: String,
default: '',
},
eventPath: {
type: String,
default: '',
},
eventBody: {
type: String,
default: '',
},
timeStamp: {
type: Number,
default: 0,
},
},
computed: {
readableTime() {
return this.dynamicTime(this.timeStamp);
},
},
methods: {
onClick() {
this.$emit('more');
},
},
};
</script>
<style lang="scss" scoped>
.timeline-card-wrap {
display: flex;
width: 100%;
color: var(--color-body);
padding: var(--space-small);
.icon-chatbox {
width: var(--space-large);
height: var(--space-large);
border-radius: 50%;
display: flex;
flex-shrink: 0;
justify-content: center;
border: 1px solid var(--color-border);
background-color: var(--color-background);
.ion-chatboxes {
font-size: var(--font-size-default);
display: flex;
align-items: center;
}
}
.card-wrap {
display: flex;
flex-direction: column;
width: 100%;
padding: var(--space-smaller) var(--space-normal) 0;
.header {
display: flex;
justify-content: space-between;
.text-wrap {
display: flex;
}
.event-path {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
.date-wrap {
font-size: var(--font-size-micro);
}
}
.comment-wrap {
border: 1px solid var(--color-border-light);
.comment {
padding: var(--space-small);
font-size: var(--font-size-mini);
margin: 0;
}
}
}
.icon-more {
.ion-android-more-vertical {
font-size: var(--font-size-medium);
}
}
}
</style>

View file

@ -1,7 +1,7 @@
<template>
<div class="custom-attributes--panel">
<contact-details-item
:title="$t('CONTACT_PANEL.CUSTOM_ATTRIBUTES.TITLE')"
:title="$t('CUSTOM_ATTRIBUTES.TITLE')"
icon="ion-code"
emoji="📕"
/>

View file

@ -9,9 +9,20 @@
{{ $t('CONVERSATION_SIDEBAR.DETAILS_TITLE') }}
</h4>
<div class="multiselect-wrap--small">
<label class="multiselect__label">
{{ $t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL') }}
</label>
<div class="self-assign">
<label class="multiselect__label">
{{ $t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL') }}
</label>
<woot-button
v-if="showSelfAssign"
icon="ion-arrow-right-c"
variant="link"
class-names="button-content"
@click="onSelfAssign"
>
{{ $t('CONVERSATION_SIDEBAR.SELF_ASSIGN') }}
</woot-button>
</div>
<div v-on-clickaway="onCloseDropdown" class="dropdown-wrap">
<button
:v-model="assignedAgent"
@ -221,6 +232,7 @@ export default {
...mapGetters({
currentChat: 'getSelectedChat',
teams: 'teams/getTeams',
currentUser: 'getCurrentUser',
getAgents: 'inboxAssignableAgents/getAssignableAgents',
uiFlags: 'inboxAssignableAgents/getUIFlags',
}),
@ -326,6 +338,15 @@ export default {
});
},
},
showSelfAssign() {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
return true;
}
return false;
},
},
watch: {
conversationId(newConversationId, prevConversationId) {
@ -380,6 +401,29 @@ export default {
onCloseDropdownTeam() {
this.showSearchDropdownTeam = false;
},
onSelfAssign() {
const {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail,
} = this.currentUser;
const selfAssign = {
account_id,
availability_status,
available_name,
email,
id,
name,
role,
thumbnail,
};
this.assignedAgent = selfAssign;
},
},
};
</script>
@ -534,4 +578,12 @@ export default {
}
}
}
.self-assign {
display: flex;
justify-content: space-between;
.button-content {
margin-bottom: var(--space-small);
}
}
</style>

View file

@ -1,136 +0,0 @@
<template>
<woot-modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header
:header-title="
$t('CONTACT_PANEL.LABELS.MODAL.TITLE') + ' #' + conversationId
"
/>
<div class="content">
<div class="label-content--block">
<div class="label-content--title">
{{ $t('CONTACT_PANEL.LABELS.MODAL.ACTIVE_LABELS') }}
<span v-tooltip.bottom="$t('CONTACT_PANEL.LABELS.MODAL.REMOVE')">
<i class="ion-ios-help-outline" />
</span>
</div>
<div v-if="activeList.length">
<woot-label
v-for="label in activeList"
:key="label.id"
:title="label.title"
:description="label.description"
:bg-color="label.color"
:show-close="true"
@click="onRemove"
/>
</div>
<p v-else>
{{ $t('CONTACT_PANEL.LABELS.NO_AVAILABLE_LABELS') }}
</p>
</div>
<div class="label-content--block">
<div class="label-content--title">
{{ $t('CONTACT_PANEL.LABELS.MODAL.INACTIVE_LABELS') }}
<span v-tooltip.bottom="$t('CONTACT_PANEL.LABELS.MODAL.ADD')">
<i class="ion-ios-help-outline" />
</span>
</div>
<div v-if="inactiveList.length">
<woot-label
v-for="label in inactiveList"
:key="label.id"
:title="label.title"
:description="label.description"
:bg-color="label.color"
icon="ion-plus"
@click="onAdd"
/>
</div>
<p v-else>
{{ $t('CONTACT_PANEL.LABELS.NO_LABELS_TO_ADD') }}
</p>
</div>
</div>
</div>
</woot-modal>
</template>
<script>
export default {
props: {
show: {
type: Boolean,
default: false,
},
conversationId: {
type: [String, Number],
default: '',
},
accountLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
},
onClose: {
type: Function,
default: () => [],
},
updateLabels: {
type: Function,
default: () => {},
},
},
computed: {
activeList() {
return this.accountLabels
.filter(accountLabel => this.savedLabels.includes(accountLabel.title))
.sort((a, b) => {
return a.title.localeCompare(b.title);
});
},
inactiveList() {
return this.accountLabels
.filter(accountLabel => !this.savedLabels.includes(accountLabel.title))
.sort((a, b) => {
return a.title.localeCompare(b.title);
});
},
},
methods: {
onAdd(label) {
const activeLabels = this.activeList.map(
activeLabel => activeLabel.title
);
this.updateLabels([...activeLabels, label]);
},
onRemove(label) {
const activeLabels = this.activeList
.filter(activeLabel => activeLabel.title !== label)
.map(activeLabel => activeLabel.title);
this.updateLabels(activeLabels);
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label-content--block {
margin-bottom: $space-normal;
}
.label-content--title {
font-weight: $font-weight-bold;
margin-bottom: $space-small;
}
.content {
padding-top: $space-normal;
}
</style>

View file

@ -49,7 +49,6 @@
</template>
<script>
/* global bus */
/* eslint no-console: 0 */
import { required, minLength } from 'vuelidate/lib/validators';

View file

@ -29,16 +29,6 @@ export default {
},
data() {
return {
channelList: [
{ key: 'website', name: 'Website' },
{ key: 'facebook', name: 'Facebook' },
{ key: 'twitter', name: 'Twitter' },
{ key: 'twilio', name: 'Twilio' },
{ key: 'email', name: 'Email' },
{ key: 'api', name: 'API' },
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
],
enabledFeatures: {},
};
},
@ -46,8 +36,26 @@ export default {
account() {
return this.$store.getters['accounts/getAccount'](this.accountId);
},
channelList() {
const { apiChannelName, apiChannelThumbnail } = this.globalConfig;
return [
{ key: 'website', name: 'Website' },
{ key: 'facebook', name: 'Facebook' },
{ key: 'twitter', name: 'Twitter' },
{ key: 'twilio', name: 'Twilio' },
{ key: 'email', name: 'Email' },
{
key: 'api',
name: apiChannelName || 'API',
thumbnail: apiChannelThumbnail,
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
];
},
...mapGetters({
accountId: 'getCurrentAccountId',
globalConfig: 'globalConfig/get',
}),
},
mounted() {

View file

@ -49,7 +49,7 @@
Email
</span>
<span v-if="item.channel_type === 'Channel::Api'">
Api
{{ globalConfig.apiChannelName || 'API' }}
</span>
</td>
@ -106,14 +106,16 @@
:inbox="selectedInbox"
/>
<woot-delete-modal
<woot-confirm-delete-modal
:show.sync="showDeletePopup"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('INBOX_MGMT.DELETE.CONFIRM.TITLE')"
:message="deleteMessage"
:message="confirmDeleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
:confirm-value="selectedInbox.name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="confirmDeletion"
@on-close="closeDelete"
/>
</div>
</template>
@ -153,11 +155,16 @@ export default {
this.selectedInbox.name
}`;
},
deleteMessage() {
confirmDeleteMessage() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.MESSAGE')} ${
this.selectedInbox.name
} ?`;
},
confirmPlaceHolderText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
inboxName: this.selectedInbox.name,
})}`;
},
},
methods: {
openSettings(inbox) {

View file

@ -1,6 +1,6 @@
<template>
<div class="settings columns container">
<woot-modal-header
<setting-intro-banner
:header-image="inbox.avatarUrl"
:header-title="inboxName"
>
@ -12,7 +12,7 @@
:show-badge="false"
/>
</woot-tabs>
</woot-modal-header>
</setting-intro-banner>
<div v-if="selectedTabKey === 'inbox_settings'" class="settings--content">
<settings-section
@ -25,13 +25,10 @@
@change="handleImageUpload"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model.trim="selectedInboxName"
class="medium-9 columns"
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL')"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
:label="inboxNameLabel"
:placeholder="inboxNamePlaceHolder"
/>
<woot-input
v-if="isAWebWidgetInbox"
@ -141,6 +138,23 @@
</p>
</label>
<label v-if="isAWebWidgetInbox" class="medium-9 columns">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX') }}
<select v-model="emailCollectEnabled">
<option :value="true">
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.ENABLED') }}
</option>
<option :value="false">
{{ $t('INBOX_MGMT.EDIT.EMAIL_COLLECT_BOX.DISABLED') }}
</option>
</select>
<p class="help-text">
{{
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_EMAIL_COLLECT_BOX_SUB_TEXT')
}}
</p>
</label>
<label class="medium-9 columns">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.AUTO_ASSIGNMENT') }}
<select v-model="autoAssignment">
@ -268,6 +282,7 @@ import { mapGetters } from 'vuex';
import { createMessengerScript } from 'dashboard/helper/scriptGenerator';
import configMixin from 'shared/mixins/configMixin';
import alertMixin from 'shared/mixins/alertMixin';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner';
import SettingsSection from '../../../../components/SettingsSection';
import inboxMixin from 'shared/mixins/inboxMixin';
import FacebookReauthorize from './facebook/Reauthorize';
@ -277,6 +292,7 @@ import Campaign from './components/Campaign';
export default {
components: {
SettingIntroBanner,
SettingsSection,
FacebookReauthorize,
PreChatFormSettings,
@ -292,6 +308,7 @@ export default {
greetingEnabled: true,
greetingMessage: '',
autoAssignment: false,
emailCollectEnabled: false,
isAgentListUpdating: false,
selectedInboxName: '',
channelWebsiteUrl: '',
@ -381,6 +398,18 @@ export default {
messengerScript() {
return createMessengerScript(this.inbox.page_id);
},
inboxNameLabel() {
if (this.isAWebWidgetInbox) {
return this.$t('INBOX_MGMT.ADD.WEBSITE_NAME.LABEL');
}
return this.$t('INBOX_MGMT.ADD.CHANNEL_NAME.LABEL');
},
inboxNamePlaceHolder() {
if (this.isAWebWidgetInbox) {
return this.$t('INBOX_MGMT.ADD.WEBSITE_NAME.PLACEHOLDER');
}
return this.$t('INBOX_MGMT.ADD.CHANNEL_NAME.PLACEHOLDER');
},
},
watch: {
$route(to) {
@ -421,6 +450,7 @@ export default {
this.greetingEnabled = this.inbox.greeting_enabled;
this.greetingMessage = this.inbox.greeting_message;
this.autoAssignment = this.inbox.enable_auto_assignment;
this.emailCollectEnabled = this.inbox.enable_email_collect;
this.channelWebsiteUrl = this.inbox.website_url;
this.channelWelcomeTitle = this.inbox.welcome_title;
this.channelWelcomeTagline = this.inbox.welcome_tagline;
@ -461,6 +491,7 @@ export default {
id: this.currentInboxId,
name: this.selectedInboxName,
enable_auto_assignment: this.autoAssignment,
enable_email_collect: this.emailCollectEnabled,
greeting_enabled: this.greetingEnabled,
greeting_message: this.greetingMessage || '',
channel: {
@ -509,15 +540,9 @@ export default {
}
}
.page-top-bar {
@include background-light;
@include border-normal-bottom;
padding: $space-normal $space-large 0;
.tabs {
padding: 0;
margin-bottom: -1px;
}
.tabs {
padding: 0;
margin-bottom: -1px;
}
}

View file

@ -15,13 +15,11 @@
>
<div class="medium-12 columns">
<label>
{{ $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.LABEL') }}
{{ $t('INBOX_MGMT.ADD.WEBSITE_NAME.LABEL') }}
<input
v-model.trim="inboxName"
type="text"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_NAME.PLACEHOLDER')
"
:placeholder="$t('INBOX_MGMT.ADD.WEBSITE_NAME.PLACEHOLDER')"
/>
</label>
</div>

View file

@ -207,8 +207,3 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.content-box .page-top-bar::v-deep {
padding: var(--space-large) var(--space-large) var(--space-zero);
}
</style>

View file

@ -239,8 +239,3 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.content-box .page-top-bar::v-deep {
padding: var(--space-large) var(--space-large) var(--space-zero);
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<div class="column content-box">
<div class="row">
<div class="empty-wrapper">
<woot-loading-state
v-if="uiFlags.isFetching"
:message="$t('INTEGRATION_APPS.FETCHING')"
/>
</div>
<div class="small-12 columns integrations-wrap">
<div class="row integrations">
<div
v-for="item in integrationsList"
:key="item.id"
class="small-12 columns integration"
>
<integration-item
:integration-id="item.id"
:integration-logo="item.logo"
:integration-name="item.name"
:integration-description="item.description"
:integration-enabled="item.hooks.length"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import IntegrationItem from './IntegrationItem';
export default {
components: {
IntegrationItem,
},
computed: {
...mapGetters({
uiFlags: 'labels/getUIFlags',
integrationsList: 'integrations/getAppIntegrations',
}),
},
mounted() {
this.$store.dispatch('integrations/get');
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.empty-wrapper {
margin: var(--space-zero) auto;
}
</style>

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