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 ### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables ## Mobile app env variables
IOS_APP_ID=6C953F3RX2.com.chatwoot.app 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 ### Smart App Banner
@ -143,6 +147,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
# maxmindb api key to use geoip2 service # maxmindb api key to use geoip2 service
# IP_LOOKUP_API_KEY= # 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 ## Development Only Config
# if you want to use letter_opener for local emails # if you want to use letter_opener for local emails
# LETTER_OPENER=true # LETTER_OPENER=true

View file

@ -44,6 +44,9 @@ Metrics/BlockLength:
- '**/routes.rb' - '**/routes.rb'
- 'config/environments/*' - 'config/environments/*'
- db/schema.rb - db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/woot_message_seeder.rb
Rails/ApplicationController: Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb' - 'app/controllers/api/v1/widget/messages_controller.rb'

View file

@ -59,6 +59,7 @@ gem 'barnes'
##--- gems for authentication & authorization ---## ##--- gems for authentication & authorization ---##
gem 'devise' gem 'devise'
gem 'devise-secure_password', '~> 2.0'
gem 'devise_token_auth' gem 'devise_token_auth'
# authorization # authorization
gem 'jwt' gem 'jwt'
@ -72,7 +73,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---## ##--- gems for channels ---##
# TODO: bump up gem to 2.0 # TODO: bump up gem to 2.0
gem 'facebook-messenger', '1.5.0' gem 'facebook-messenger'
gem 'telegram-bot-ruby' gem 'telegram-bot-ruby'
gem 'twilio-ruby', '~> 5.32.0' gem 'twilio-ruby', '~> 5.32.0'
# twitty will handle subscription of twitter account events # twitty will handle subscription of twitter account events
@ -132,8 +133,6 @@ group :test do
end end
group :development, :test do 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 'bundle-audit', require: false
gem 'byebug', platform: :mri gem 'byebug', platform: :mri
gem 'factory_bot_rails' gem 'factory_bot_rails'

View file

@ -16,8 +16,6 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action-cable-testing (0.6.1)
actioncable (>= 5.0)
actioncable (6.0.3.7) actioncable (6.0.3.7)
actionpack (= 6.0.3.7) actionpack (= 6.0.3.7)
nio4r (~> 2.0) nio4r (~> 2.0)
@ -125,7 +123,7 @@ GEM
barnes (0.0.8) barnes (0.0.8)
multi_json (~> 1) multi_json (~> 1)
statsd-ruby (~> 1.1) statsd-ruby (~> 1.1)
bcrypt (3.1.15) bcrypt (3.1.16)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.4.8) bootsnap (1.4.8)
msgpack (~> 1.0) msgpack (~> 1.0)
@ -160,12 +158,15 @@ GEM
declarative-option (0.1.0) declarative-option (0.1.0)
descendants_tracker (0.0.4) descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1) thread_safe (~> 0.3, >= 0.3.1)
devise (4.7.2) devise (4.8.0)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) 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) devise_token_auth (1.1.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
@ -188,7 +189,7 @@ GEM
et-orbi (1.2.4) et-orbi (1.2.4)
tzinfo tzinfo
execjs (2.7.0) execjs (2.7.0)
facebook-messenger (1.5.0) facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7) httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5) rack (>= 1.4.5)
factory_bot (6.1.0) factory_bot (6.1.0)
@ -261,7 +262,7 @@ GEM
multi_json (~> 1.11) multi_json (~> 1.11)
os (>= 0.9, < 2.0) os (>= 0.9, < 2.0)
signet (~> 0.14) signet (~> 0.14)
groupdate (5.1.0) groupdate (5.2.2)
activesupport (>= 5) activesupport (>= 5)
grpc (1.37.1) grpc (1.37.1)
google-protobuf (~> 3.15) google-protobuf (~> 3.15)
@ -335,7 +336,7 @@ GEM
method_source (1.0.0) method_source (1.0.0)
mime-types (3.3.1) mime-types (3.3.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2020.0512) mime-types-data (3.2021.0225)
mini_magick (4.10.1) mini_magick (4.10.1)
mini_mime (1.1.0) mini_mime (1.1.0)
mini_portile2 (2.5.1) mini_portile2 (2.5.1)
@ -350,7 +351,7 @@ GEM
connection_pool (~> 2.2) connection_pool (~> 2.2)
netrc (0.11.0) netrc (0.11.0)
nio4r (2.5.7) nio4r (2.5.7)
nokogiri (1.11.4) nokogiri (1.11.6)
mini_portile2 (~> 2.5.0) mini_portile2 (~> 2.5.0)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.6) oauth (0.5.6)
@ -584,8 +585,8 @@ GEM
coercible (~> 1.0) coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3) descendants_tracker (~> 0.0, >= 0.0.3)
equalizer (~> 0.0, >= 0.0.9) equalizer (~> 0.0, >= 0.0.9)
warden (1.2.8) warden (1.2.9)
rack (>= 2.0.6) rack (>= 2.0.9)
web-console (4.0.4) web-console (4.0.4)
actionview (>= 6.0.0) actionview (>= 6.0.0)
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
@ -613,7 +614,6 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
action-cable-testing
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate administrate
@ -632,9 +632,10 @@ DEPENDENCIES
cypress-on-rails (~> 1.0) cypress-on-rails (~> 1.0)
database_cleaner database_cleaner
devise devise
devise-secure_password (~> 2.0)
devise_token_auth devise_token_auth
dotenv-rails dotenv-rails
facebook-messenger (= 1.5.0) facebook-messenger
factory_bot_rails factory_bot_rails
faker faker
fcm fcm

View file

@ -1,15 +1,13 @@
require 'facebook/messenger' require 'facebook/messenger'
class FacebookBot class FacebookBot
include Facebook::Messenger Facebook::Messenger::Bot.on :message do |message|
Bot.on :message do |message|
Rails.logger.info "MESSAGE_RECIEVED #{message}" Rails.logger.info "MESSAGE_RECIEVED #{message}"
response = ::Integrations::Facebook::MessageParser.new(message) response = ::Integrations::Facebook::MessageParser.new(message)
::Integrations::Facebook::MessageCreator.new(response).perform ::Integrations::Facebook::MessageCreator.new(response).perform
end end
Bot.on :delivery do |delivery| Facebook::Messenger::Bot.on :delivery do |delivery|
# delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38' # delivery.ids # => 'mid.1457764197618:41d102a3e1ae206a38'
# delivery.sender # => { 'id' => '1008372609250235' } # delivery.sender # => { 'id' => '1008372609250235' }
# delivery.recipient # => { 'id' => '2015573629214912' } # delivery.recipient # => { 'id' => '2015573629214912' }
@ -20,7 +18,7 @@ class FacebookBot
Rails.logger.info "Human was online at #{delivery.at}" Rails.logger.info "Human was online at #{delivery.at}"
end end
Bot.on :message_echo do |message| Facebook::Messenger::Bot.on :message_echo do |message|
Rails.logger.info "MESSAGE_ECHO #{message}" Rails.logger.info "MESSAGE_ECHO #{message}"
response = ::Integrations::Facebook::MessageParser.new(message) response = ::Integrations::Facebook::MessageParser.new(message)
::Integrations::Facebook::MessageCreator.new(response).perform ::Integrations::Facebook::MessageCreator.new(response).perform

View file

@ -2,7 +2,7 @@
class AccountBuilder class AccountBuilder
include CustomExceptions::Account 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 def perform
if @user.nil? if @user.nil?
@ -61,11 +61,9 @@ class AccountBuilder
end end
def create_user def create_user
password = user_password || SecureRandom.alphanumeric(12)
@user = User.new(email: @email, @user = User.new(email: @email,
password: password, password: user_password,
password_confirmation: password, password_confirmation: user_password,
name: @user_full_name) name: @user_full_name)
@user.confirm if @confirmed @user.confirm if @confirmed
@user.save! @user.save!

View file

@ -13,6 +13,7 @@ class Messages::Facebook::MessageBuilder
@outgoing_echo = outgoing_echo @outgoing_echo = outgoing_echo
@sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id) @sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (@outgoing_echo ? :outgoing : :incoming) @message_type = (@outgoing_echo ? :outgoing : :incoming)
@attachments = (@response.attachments || [])
end end
def perform def perform
@ -41,13 +42,19 @@ class Messages::Facebook::MessageBuilder
def build_message def build_message
@message = conversation.messages.create!(message_params) @message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment| @attachments.each do |attachment|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url)) process_attachment(attachment)
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end end
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) def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url) file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.filename, content_type: file_resource.encoding) 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 end
def sender 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 end
def message_params def message_params
@ -54,6 +64,6 @@ class Messages::MessageBuilder
items: @items, items: @items,
in_reply_to: @in_reply_to, in_reply_to: @in_reply_to,
echo_id: @params[:echo_id] echo_id: @params[:echo_id]
} }.merge(external_created_at)
end end
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) authorize(model)
end end
def check_admin_authorization?
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
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]) @user = User.find_by(email: new_agent_params[:email])
end 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 def create_user
return if @user return if @user
@ -58,9 +60,10 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end end
def new_agent_params 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) 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 end
def agents def agents

View file

@ -11,6 +11,7 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
def ensure_inbox def ensure_inbox
@inbox = Current.account.inboxes.find(params[:inbox_id]) @inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end end
def ensure_contact def ensure_contact

View file

@ -8,9 +8,7 @@ class Api::V1::Accounts::Contacts::ConversationsController < Api::V1::Accounts::
private private
def inbox_ids def inbox_ids
if Current.user.administrator? if Current.user.administrator? || Current.user.agent?
Current.account.inboxes.pluck(:id)
elsif Current.user.agent?
Current.user.assigned_inboxes.pluck(:id) Current.user.assigned_inboxes.pluck(:id)
else else
[] []

View file

@ -48,7 +48,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end def show; end
def contactable_inboxes 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 end
def create def create

View file

@ -5,5 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base
def conversation def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id]) @conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
authorize @conversation.inbox, :show?
end end
end end

View file

@ -1,7 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types include Events::Types
before_action :conversation, except: [:index] before_action :conversation, except: [:index, :meta, :search, :create]
before_action :contact_inbox, only: [:create] before_action :contact_inbox, only: [:create]
def index def index
@ -41,7 +41,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def transcript 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 head :ok
end end
@ -77,34 +79,40 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end end
def conversation 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 end
def contact_inbox def contact_inbox
@contact_inbox = build_contact_inbox @contact_inbox = build_contact_inbox
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id]) @contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
end end
def build_contact_inbox def build_contact_inbox
return if params[:contact_id].blank? || params[:inbox_id].blank? return if params[:contact_id].blank? || params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
authorize inbox, :show?
ContactInboxBuilder.new( ContactInboxBuilder.new(
contact_id: params[:contact_id], contact_id: params[:contact_id],
inbox_id: params[:inbox_id], inbox_id: inbox.id,
source_id: params[:source_id] source_id: params[:source_id]
).perform ).perform
end end
def conversation_params def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {} additional_attributes = params[:additional_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
{ {
account_id: Current.account.id, account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id, inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id, contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id, contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes additional_attributes: additional_attributes
} }.merge(status)
end end
def conversation_finder 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] before_action :current_agents_ids, only: [:create]
def create def create
# update also done via same action authorize @inbox, :create?
update_agents_list begin
head :ok # update also done via same action
rescue StandardError => e update_agents_list
Rails.logger.debug "Rescued: #{e.inspect}" head :ok
render_could_not_create_error('Could not add agents to inbox') rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
render_could_not_create_error('Could not add agents to inbox')
end
end end
def show def show
authorize @inbox, :show?
@agents = Current.account.users.where(id: @inbox.members.select(:user_id)) @agents = Current.account.users.where(id: @inbox.members.select(:user_id))
end end

View file

@ -38,6 +38,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
update_channel_feature_flags update_channel_feature_flags
end end
def agent_bot
@agent_bot = @inbox.agent_bot
end
def set_agent_bot def set_agent_bot
if @agent_bot if @agent_bot
agent_bot_inbox = @inbox.agent_bot_inbox || AgentBotInbox.new(inbox: @inbox) 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 def fetch_inbox
@inbox = Current.account.inboxes.find(params[:id]) @inbox = Current.account.inboxes.find(params[:id])
authorize @inbox, :show?
end end
def fetch_agent_bot def fetch_agent_bot
@ -83,12 +88,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end end
def permitted_params 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]) [:type, :website_url, :widget_color, :welcome_title, :welcome_tagline, :webhook_url, :email, :reply_time])
end end
def inbox_update_params 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, :working_hours_enabled, :out_of_office_message, :timezone,
channel: [ channel: [
:website_url, :website_url,

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_apps, only: [:index] before_action :fetch_apps, only: [:index]
before_action :fetch_app, only: [:show] before_action :fetch_app, only: [:show]

View file

@ -1,4 +1,5 @@
class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook, only: [:update, :destroy] before_action :fetch_hook, only: [:update, :destroy]
def create def create

View file

@ -18,7 +18,7 @@ class Api::V1::AccountsController < Api::BaseController
account_name: account_params[:account_name], account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name], user_full_name: account_params[:user_full_name],
email: account_params[:email], email: account_params[:email],
confirmed: confirmed?, user_password: account_params[:password],
user: current_user user: current_user
).perform ).perform
if @user if @user
@ -46,17 +46,13 @@ class Api::V1::AccountsController < Api::BaseController
private private
def confirmed?
super_admin? && params[:confirmed]
end
def fetch_account def fetch_account
@account = current_user.accounts.find(params[:id]) @account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id) @current_account_user = @account.account_users.find_by(user_id: current_user.id)
end end
def account_params 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 end
def check_signup_enabled 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 end
def update 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) @user.update!(profile_params)
end end
@ -20,11 +26,17 @@ class Api::V1::ProfilesController < Api::BaseController
:email, :email,
:name, :name,
:display_name, :display_name,
:password,
:password_confirmation,
:avatar, :avatar,
:availability, :availability,
ui_settings: {} ui_settings: {}
) )
end end
def password_params
params.require(:profile).permit(
:current_password,
:password,
:password_confirmation
)
end
end end

View file

@ -23,7 +23,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def transcript def transcript
if permitted_params[:email].present? && conversation.present? if permitted_params[:email].present? && conversation.present?
ConversationReplyMailer.conversation_transcript( ConversationReplyMailer.with(account: conversation.account).conversation_transcript(
conversation, conversation,
permitted_params[:email] permitted_params[:email]
)&.deliver_later )&.deliver_later

View file

@ -20,7 +20,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
@message.update!(message_update_params[:message]) @message.update!(message_update_params[:message])
end end
rescue StandardError => e 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 end
private private

View file

@ -1,4 +1,6 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def account def account
builder = V2::ReportBuilder.new(Current.account, account_report_params) builder = V2::ReportBuilder.new(Current.account, account_report_params)
data = builder.build data = builder.build
@ -23,6 +25,10 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
private private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
def account_summary_params def account_summary_params
{ {
type: :account, type: :account,

View file

@ -17,13 +17,8 @@ module AccessTokenAuthHelper
Current.user = @resource if current_user.is_a?(User) Current.user = @resource if current_user.is_a?(User)
end end
def super_admin?
@resource.present? && @resource.is_a?(SuperAdmin)
end
def validate_bot_access_token! def validate_bot_access_token!
return if Current.user.is_a?(User) return if Current.user.is_a?(User)
return if super_admin?
return if agent_bot_accessible? return if agent_bot_accessible?
render_unauthorized('Access to this endpoint is not authorized for bots') render_unauthorized('Access to this endpoint is not authorized for bots')

View file

@ -21,7 +21,9 @@ class DashboardController < ActionController::Base
'PRIVACY_URL', 'PRIVACY_URL',
'DISPLAY_MANIFEST', 'DISPLAY_MANIFEST',
'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD',
'CHATWOOT_INBOX_TOKEN' 'CHATWOOT_INBOX_TOKEN',
'API_CHANNEL_NAME',
'API_CHANNEL_THUMBNAIL'
).merge( ).merge(
APP_VERSION: Chatwoot.config[:version] APP_VERSION: Chatwoot.config[:version]
) )

View file

@ -1,34 +1,29 @@
class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController class DeviseOverrides::ConfirmationsController < Devise::ConfirmationsController
include AuthHelper
skip_before_action :require_no_authentication, raise: false skip_before_action :require_no_authentication, raise: false
skip_before_action :authenticate_user!, raise: false skip_before_action :authenticate_user!, raise: false
def create def create
@confirmable = User.find_by(confirmation_token: params[:confirmation_token]) @confirmable = User.find_by(confirmation_token: params[:confirmation_token])
render_confirmation_success and return if @confirmable&.confirm
if confirm render_confirmation_error
render_confirmation_success
else
render_confirmation_error
end
end end
protected private
def confirm
@confirmable&.confirm || (@confirmable&.confirmed_at && @confirmable&.reset_password_token)
end
def render_confirmation_success 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 end
def render_confirmation_error def render_confirmation_error
if @confirmable.blank? 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 elsif @confirmable.confirmed_at
render json: { "message": 'Already confirmed', "redirect_url": '/' }, status: 422 render json: { message: 'Already confirmed', redirect_url: '/' }, status: :unprocessable_entity
else else
render json: { "message": 'Failure', "redirect_url": '/' }, status: 422 render json: { message: 'Failure', redirect_url: '/' }, status: :unprocessable_entity
end end
end end

View file

@ -13,7 +13,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
send_auth_headers(@recoverable) send_auth_headers(@recoverable)
render partial: 'devise/auth.json', locals: { resource: @recoverable } render partial: 'devise/auth.json', locals: { resource: @recoverable }
else else
render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422 render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
end end
end end
@ -27,7 +27,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
end end
end end
protected private
def reset_password_and_confirmation(recoverable) def reset_password_and_confirmation(recoverable)
recoverable.confirm unless recoverable.confirmed? # confirm if user resets password without confirming anytime before 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) def build_response(message, status)
render json: { render json: {
"message": message message: message
}, status: status }, status: status
end end
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, description: Field::String,
outgoing_url: Field::String, outgoing_url: Field::String,
created_at: Field::DateTime, created_at: Field::DateTime,
updated_at: Field::DateTime, updated_at: Field::DateTime
hide_input_for_bot_conversations: Field::Boolean
}.freeze }.freeze
# COLLECTION_ATTRIBUTES # COLLECTION_ATTRIBUTES
@ -39,7 +38,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
name name
description description
outgoing_url outgoing_url
hide_input_for_bot_conversations
].freeze ].freeze
# FORM_ATTRIBUTES # FORM_ATTRIBUTES
@ -49,7 +47,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
name name
description description
outgoing_url outgoing_url
hide_input_for_bot_conversations
].freeze ].freeze
# COLLECTION_FILTERS # COLLECTION_FILTERS

View file

@ -11,7 +11,6 @@ class SuperAdminDashboard < Administrate::BaseDashboard
id: Field::Number, id: Field::Number,
email: Field::String, email: Field::String,
password: Field::Password, password: Field::Password,
access_token: Field::HasOne,
remember_created_at: Field::DateTime, remember_created_at: Field::DateTime,
sign_in_count: Field::Number, sign_in_count: Field::Number,
current_sign_in_at: Field::DateTime, current_sign_in_at: Field::DateTime,
@ -30,7 +29,6 @@ class SuperAdminDashboard < Administrate::BaseDashboard
COLLECTION_ATTRIBUTES = %i[ COLLECTION_ATTRIBUTES = %i[
id id
email email
access_token
].freeze ].freeze
# SHOW_PAGE_ATTRIBUTES # SHOW_PAGE_ATTRIBUTES

View file

@ -48,15 +48,11 @@ class ConversationFinder
private private
def set_inboxes def set_inboxes
if params[:inbox_id] @inbox_ids = if params[:inbox_id]
@inbox_ids = current_account.inboxes.where(id: params[:inbox_id]) current_account.inboxes.where(id: params[:inbox_id])
else else
if @current_user.administrator? @current_user.assigned_inboxes.pluck(:id)
@inbox_ids = current_account.inboxes.pluck(:id) end
elsif @current_user.agent?
@inbox_ids = @current_user.assigned_inboxes.pluck(:id)
end
end
end end
def set_assignee_type def set_assignee_type

View file

@ -29,6 +29,7 @@ export default {
account_name: creds.accountName.trim(), account_name: creds.accountName.trim(),
user_full_name: creds.fullName.trim(), user_full_name: creds.fullName.trim(),
email: creds.email, email: creds.email,
password: creds.password,
}) })
.then(response => { .then(response => {
setAuthCredentials(response); setAuthCredentials(response);
@ -95,8 +96,18 @@ export default {
}, },
verifyPasswordToken({ confirmationToken }) { verifyPasswordToken({ confirmationToken }) {
return axios.post('auth/confirmation', { return new Promise((resolve, reject) => {
confirmation_token: confirmationToken, 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) { delete(integrationId) {
return axios.delete(`${this.baseUrl()}/integrations/${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(); export default new IntegrationsAPI();

View file

@ -5,6 +5,7 @@ function apiSpecHelper() {
post: jest.fn(() => Promise.resolve()), post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()), get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()), patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
}; };
window.axios = this.axiosMock; 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 'mixins';
@import 'foundation-settings'; @import 'foundation-settings';
@import 'helper-classes'; @import 'helper-classes';
@import 'formulate';
@import 'foundation-sites/scss/foundation'; @import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon'; @import '~bourbon/core/bourbon';
@include foundation-everything($flex: true); @include foundation-everything($flex: true);
@import 'typography'; @import 'typography';
@import 'layout'; @import 'layout';
@import 'animations'; @import 'animations';
@ -46,5 +46,4 @@
@import 'plugins/multiselect'; @import 'plugins/multiselect';
@import 'plugins/dropdown'; @import 'plugins/dropdown';
@import '@chatwoot/prosemirror-schema/src/woot-editor.css';
@import '~shared/assets/stylesheets/ionicons'; @import '~shared/assets/stylesheets/ionicons';

View file

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

View file

@ -1,9 +1,5 @@
.settings { .settings {
overflow: auto; overflow: auto;
.page-top-bar {
@include padding($space-normal $space-two $zero);
}
} }
// Conversation header - Light BG // Conversation header - Light BG
@ -27,7 +23,6 @@
@include flex-align($x: center, $y: middle); @include flex-align($x: center, $y: middle);
@include margin($zero); @include margin($zero);
} }
} }
.wizard-box { .wizard-box {

View file

@ -1,31 +1,24 @@
$default-button-height: 4.0rem;
.button { .button {
align-items: center; align-items: center;
display: inline-flex; display: inline-flex;
height: 4.0rem; height: $default-button-height;
margin-bottom: 0; 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 { .spinner {
padding: 0 var(--space-small); 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 { .icon+.button__content {
padding-left: var(--space-small); padding-left: var(--space-small);
} }
@ -48,23 +41,23 @@
// Smooth style // Smooth style
&.smooth { &.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 { &.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 { &.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 { &.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 { &.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); 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 { &.link {
height: auto; height: auto;
margin: 0; 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" class-names="resolve"
color-scheme="success" color-scheme="success"
icon="ion-checkmark" icon="ion-checkmark"
emoji="✅"
:is-loading="isLoading" :is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.RESOLVED)" @click="() => toggleStatus(STATUS_TYPE.RESOLVED)"
> >
@ -16,6 +17,7 @@
class-names="resolve" class-names="resolve"
color-scheme="warning" color-scheme="warning"
icon="ion-refresh" icon="ion-refresh"
emoji="👀"
:is-loading="isLoading" :is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.OPEN)" @click="() => toggleStatus(STATUS_TYPE.OPEN)"
> >
@ -36,9 +38,9 @@
:color-scheme="buttonClass" :color-scheme="buttonClass"
:disabled="isLoading" :disabled="isLoading"
icon="ion-arrow-down-b" icon="ion-arrow-down-b"
emoji="🔽"
@click="openDropdown" @click="openDropdown"
> />
</woot-button>
</div> </div>
<div <div
v-if="showDropdown" v-if="showDropdown"

View file

@ -6,6 +6,7 @@ import Button from './ui/WootButton';
import Code from './Code'; import Code from './Code';
import ColorPicker from './widgets/ColorPicker'; import ColorPicker from './widgets/ColorPicker';
import DeleteModal from './widgets/modal/DeleteModal.vue'; import DeleteModal from './widgets/modal/DeleteModal.vue';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem'; import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu'; import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import Input from './widgets/forms/Input.vue'; import Input from './widgets/forms/Input.vue';
@ -42,6 +43,7 @@ const WootUIKit = {
Tabs, Tabs,
TabsItem, TabsItem,
Thumbnail, Thumbnail,
ConfirmDeleteModal,
install(Vue) { install(Vue) {
const keys = Object.keys(this); const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys keys.pop(); // remove 'install' from keys

View file

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

View file

@ -52,7 +52,11 @@ export default {
}, },
labelStyle() { labelStyle() {
if (this.bgColor) { if (this.bgColor) {
return { background: this.bgColor, color: this.textColor }; return {
background: this.bgColor,
color: this.textColor,
border: `1px solid ${this.bgColor}`,
};
} }
return {}; return {};
}, },

View file

@ -6,16 +6,22 @@
@click="handleClick" @click="handleClick"
> >
<spinner v-if="isLoading" size="small" /> <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> <span v-if="$slots.default" class="button__content"><slot></slot></span>
</button> </button>
</template> </template>
<script> <script>
import Spinner from 'shared/components/Spinner.vue'; import Spinner from 'shared/components/Spinner';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
export default { export default {
name: 'WootButton', name: 'WootButton',
components: { Spinner }, components: { EmojiOrIcon, Spinner },
props: { props: {
variant: { variant: {
type: String, type: String,
@ -29,12 +35,16 @@ export default {
type: String, type: String,
default: '', default: '',
}, },
emoji: {
type: String,
default: '',
},
colorScheme: { colorScheme: {
type: String, type: String,
default: 'primary', default: 'primary',
}, },
classNames: { classNames: {
type: String, type: [String, Object],
default: '', default: '',
}, },
isDisabled: { isDisabled: {
@ -57,9 +67,15 @@ export default {
} }
return this.variant; return this.variant;
}, },
hasOnlyIconClasses() {
const hasEmojiOrIcon = this.emoji || this.icon;
if (!this.$slots.default && hasEmojiOrIcon) return 'button--only-icon';
return '';
},
buttonClasses() { buttonClasses() {
return [ return [
this.variantClasses, this.variantClasses,
this.hasOnlyIconClasses,
this.size, this.size,
this.colorScheme, this.colorScheme,
this.classNames, 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" src="~dashboard/assets/images/channels/telegram.png"
/> />
<img <img
v-if="channel.key === 'api'" v-if="channel.key === 'api' && !channel.thumbnail"
src="~dashboard/assets/images/channels/api.png" src="~dashboard/assets/images/channels/api.png"
/> />
<img
v-if="channel.key === 'api' && channel.thumbnail"
:src="channel.thumbnail"
/>
<img <img
v-if="channel.key === 'email'" v-if="channel.key === 'email'"
src="~dashboard/assets/images/channels/email.png" src="~dashboard/assets/images/channels/email.png"

View file

@ -1,21 +1,23 @@
<template> <template>
<div class="inbox-item" > <div class="inbox-item">
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image"/> <img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image" />
<div class="item--details columns"> <div class="item--details columns">
<h4 class="item--name">{{ inbox.label }}</h4> <h4 class="item--name">
<p class="item--sub">Facebook</p> {{ inbox.label }}
</h4>
<p class="item--sub">
Facebook
</p>
</div> </div>
<!-- <span class="ion-chevron-right arrow"></span> --> <!-- <span class="ion-chevron-right arrow"></span> -->
</div> </div>
</template> </template>
<script> <script>
/* eslint no-console: 0 */ /* eslint no-console: 0 */
/* global bus */
// import WootSwitch from '../ui/Switch'; // import WootSwitch from '../ui/Switch';
export default { export default {
props: ['inbox'], props: ['inbox'],
created() { created() {},
},
}; };
</script> </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" :search-key="mentionSearchKey"
@click="insertMentionNode" @click="insertMentionNode"
/> />
<canned-response
v-if="showCannedMenu"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
/>
<div ref="editor"></div> <div ref="editor"></div>
</div> </div>
</template> </template>
<script> <script>
import { EditorView } from 'prosemirror-view'; import { EditorView } from 'prosemirror-view';
import { defaultMarkdownSerializer } from 'prosemirror-markdown'; import { defaultMarkdownSerializer } from 'prosemirror-markdown';
const TYPING_INDICATOR_IDLE_TIME = 4000;
import { import {
addMentionsToMarkdownSerializer, addMentionsToMarkdownSerializer,
addMentionsToMarkdownParser, addMentionsToMarkdownParser,
schemaWithMentions, schemaWithMentions,
} from '@chatwoot/prosemirror-schema/src/mentions/schema'; } from '@chatwoot/prosemirror-schema/src/mentions/schema';
import { import {
suggestionsPlugin, suggestionsPlugin,
triggerCharacters, triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin'; } from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import TagAgents from '../conversation/TagAgents.vue';
import { EditorState } from 'prosemirror-state'; import { EditorState } from 'prosemirror-state';
import { defaultMarkdownParser } from 'prosemirror-markdown'; import { defaultMarkdownParser } from 'prosemirror-markdown';
import { wootWriterSetup } from '@chatwoot/prosemirror-schema'; 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 = []) => { const createState = (content, placeholder, plugins = []) => {
return EditorState.create({ return EditorState.create({
doc: addMentionsToMarkdownParser(defaultMarkdownParser).parse(content), doc: addMentionsToMarkdownParser(defaultMarkdownParser).parse(content),
@ -42,7 +52,7 @@ const createState = (content, placeholder, plugins = []) => {
export default { export default {
name: 'WootMessageEditor', name: 'WootMessageEditor',
components: { TagAgents }, components: { TagAgents, CannedResponse },
props: { props: {
value: { type: String, default: '' }, value: { type: String, default: '' },
placeholder: { type: String, default: '' }, placeholder: { type: String, default: '' },
@ -52,7 +62,9 @@ export default {
return { return {
lastValue: null, lastValue: null,
showUserMentions: false, showUserMentions: false,
showCannedMenu: false,
mentionSearchKey: '', mentionSearchKey: '',
cannedSearchTerm: '',
editorView: null, editorView: null,
range: null, range: null,
}; };
@ -85,6 +97,35 @@ export default {
return event.keyCode === 13 && this.showUserMentions; 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) { showUserMentions(updatedValue) {
this.$emit('toggle-user-mention', this.isPrivate && 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) { 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); this.view.updateState(this.state);
} }
}, },
@ -140,6 +186,21 @@ export default {
this.state = this.view.state.apply(tr); this.state = this.view.state.apply(tr);
return this.emitOnChange(); 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() { emitOnChange() {
this.view.updateState(this.state); this.view.updateState(this.state);
this.lastValue = addMentionsToMarkdownSerializer( this.lastValue = addMentionsToMarkdownSerializer(
@ -205,10 +266,9 @@ export default {
.is-private { .is-private {
.prosemirror-mention-node { .prosemirror-mention-node {
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
background: var(--s-300); background: var(--s-50);
border-radius: var(--border-radius-small); color: var(--s-900);
padding: 1px 4px; padding: 0 var(--space-smaller);
color: var(--white);
} }
} }
</style> </style>

View file

@ -1,34 +1,42 @@
<template> <template>
<div class="bottom-box" :class="wrapClass"> <div class="bottom-box" :class="wrapClass">
<div class="left-wrap"> <div class="left-wrap">
<button <woot-button
class="button clear button--emoji"
:title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')" :title="$t('CONVERSATION.REPLYBOX.TIP_EMOJI_ICON')"
icon="ion-happy-outline"
emoji="😊"
color-scheme="secondary"
variant="smooth"
size="small"
@click="toggleEmojiPicker" @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="😊" /> <woot-button
</button> v-if="showAttachButton"
<button class-names="button--upload"
v-if="showAttachButton" :title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
class="button clear button--emoji button--upload" icon="ion-android-attach"
:title="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')" emoji="📎"
> color-scheme="secondary"
<file-upload variant="smooth"
:size="4096 * 4096" size="small"
accept="image/*, application/pdf, audio/mpeg, video/mp4, audio/ogg, text/csv" />
@input-file="onFileUpload" </file-upload>
> <woot-button
<emoji-or-icon icon="ion-android-attach" emoji="📎" />
</file-upload>
</button>
<button
v-if="enableRichEditor && !isOnPrivateNote" 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')" :title="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
@click="toggleFormatMode" @click="toggleFormatMode"
> />
<emoji-or-icon icon="ion-quote" emoji="🖊️" />
</button>
</div> </div>
<div class="right-wrap"> <div class="right-wrap">
<div v-if="isFormatMode" class="enter-to-send--checkbox"> <div v-if="isFormatMode" class="enter-to-send--checkbox">
@ -42,25 +50,25 @@
{{ $t('CONVERSATION.REPLYBOX.ENTER_TO_SEND') }} {{ $t('CONVERSATION.REPLYBOX.ENTER_TO_SEND') }}
</label> </label>
</div> </div>
<button <woot-button
class="button nice primary button--send" size="small"
:class="buttonClass" :class-names="buttonClass"
:is-disabled="isSendDisabled"
@click="onSend" @click="onSend"
> >
{{ sendButtonText }} {{ sendButtonText }}
</button> </woot-button>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import FileUpload from 'vue-upload-component'; import FileUpload from 'vue-upload-component';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
import { REPLY_EDITOR_MODES } from './constants'; import { REPLY_EDITOR_MODES } from './constants';
export default { export default {
name: 'ReplyTopPanel', name: 'ReplyTopPanel',
components: { EmojiOrIcon, FileUpload }, components: { FileUpload },
props: { props: {
mode: { mode: {
type: String, type: String,
@ -126,8 +134,7 @@ export default {
}, },
buttonClass() { buttonClass() {
return { return {
'button--note': this.isNote, warning: this.isNote,
'button--disabled': this.isSendDisabled,
}; };
}, },
showAttachButton() { showAttachButton() {
@ -146,9 +153,6 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~widget/assets/scss/variables.scss';
@import '~widget/assets/scss/mixins.scss';
.bottom-box { .bottom-box {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -159,39 +163,8 @@ export default {
} }
} }
.button { .left-wrap .button {
&.button--emoji { margin-right: var(--space-small);
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 { .left-wrap {
@ -199,15 +172,6 @@ export default {
display: flex; 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 { .right-wrap {
display: flex; display: flex;
@ -225,4 +189,13 @@ export default {
} }
} }
} }
::v-deep .file-uploads {
label {
cursor: pointer;
}
&:hover .button {
background: var(--s-100);
}
}
</style> </style>

View file

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

View file

@ -42,6 +42,7 @@
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@toggle-user-mention="toggleUserMention" @toggle-user-mention="toggleUserMention"
@toggle-canned-menu="toggleCannedMenu"
/> />
</div> </div>
<div v-if="hasAttachments" class="attachment-preview-box"> <div v-if="hasAttachments" class="attachment-preview-box">
@ -249,7 +250,8 @@ export default {
} }
}, },
message(updatedMessage) { message(updatedMessage) {
this.hasSlashCommand = updatedMessage[0] === '/'; this.hasSlashCommand =
updatedMessage[0] === '/' && !this.showRichContentEditor;
const hasNextWord = updatedMessage.includes(' '); const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord; const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) { if (isShortCodeActive) {
@ -271,6 +273,9 @@ export default {
toggleUserMention(currentMentionState) { toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState; this.hasUserMention = currentMentionState;
}, },
toggleCannedMenu(value) {
this.showCannedMenu = value;
},
handleKeyEvents(e) { handleKeyEvents(e) {
if (isEscape(e)) { if (isEscape(e)) {
this.hideEmojiPicker(); this.hideEmojiPicker();
@ -279,7 +284,8 @@ export default {
const hasSendOnEnterEnabled = const hasSendOnEnterEnabled =
(this.showRichContentEditor && (this.showRichContentEditor &&
this.enterToSendEnabled && this.enterToSendEnabled &&
!this.hasUserMention) || !this.hasUserMention &&
!this.showCannedMenu) ||
!this.showRichContentEditor; !this.showRichContentEditor;
const shouldSendMessage = const shouldSendMessage =
hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused; 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 { export default {
APP_BASE_URL: '/',
get apiURL() {
return `${this.APP_BASE_URL}/`;
},
GRAVATAR_URL: 'https://www.gravatar.com/avatar/', GRAVATAR_URL: 'https://www.gravatar.com/avatar/',
ASSIGNEE_TYPE: { ASSIGNEE_TYPE: {
ME: 'me', ME: 'me',
@ -15,3 +11,4 @@ export default {
BOT: 'bot', BOT: 'bot',
}, },
}; };
export const DEFAULT_REDIRECT_URL = '/app/';

View file

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

View file

@ -4,7 +4,8 @@ import { newMessageNotification } from 'shared/helpers/AudioNotificationHelper';
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {
super(app, pubsubToken); const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = []; this.CancelTyping = [];
this.events = { this.events = {
'message.created': this.onMessageCreated, '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 = []) => { export const getTypingUsersText = (users = []) => {
const count = users.length; const count = users.length;
if (count === 1) { if (count === 1) {

View file

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

View file

@ -155,6 +155,16 @@
"VIEW_DETAILS": "View details" "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": { "NOTES": {
"HEADER": { "HEADER": {
"TITLE": "Notes" "TITLE": "Notes"
@ -168,6 +178,16 @@
"BUTTON": "View all notes" "BUTTON": "View all notes"
} }
}, },
"EVENTS": {
"HEADER": {
"TITLE": "Activities"
},
"BUTTON": {
"PILL_BUTTON_NOTES": "notes",
"PILL_BUTTON_EVENTS": "events",
"PILL_BUTTON_CONVO": "conversations"
}
},
"CUSTOM_ATTRIBUTES": { "CUSTOM_ATTRIBUTES": {
"TITLE": "Custom Attributes", "TITLE": "Custom Attributes",
"BUTTON": "Add custom attribute", "BUTTON": "Add custom attribute",

View file

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

View file

@ -28,6 +28,14 @@
} }
], ],
"ADD": { "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": { "FB": {
"HELP": "PS: By signing in, we only get access to your Page's messages. Your private messages can never be accessed by Chatwoot.", "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", "CHOOSE_PAGE": "Choose Page",
@ -48,10 +56,6 @@
"CHANNEL_AVATAR": { "CHANNEL_AVATAR": {
"LABEL": "Channel Avatar" "LABEL": "Channel Avatar"
}, },
"CHANNEL_NAME": {
"LABEL": "Website Name",
"PLACEHOLDER": "Enter your website name (eg: Acme Inc)"
},
"CHANNEL_DOMAIN": { "CHANNEL_DOMAIN": {
"LABEL": "Website Domain", "LABEL": "Website Domain",
"PLACEHOLDER": "Enter your website domain (eg: acme.com)" "PLACEHOLDER": "Enter your website domain (eg: acme.com)"
@ -208,6 +212,10 @@
"AUTO_ASSIGNMENT": { "AUTO_ASSIGNMENT": {
"ENABLED": "Enabled", "ENABLED": "Enabled",
"DISABLED": "Disabled" "DISABLED": "Disabled"
},
"EMAIL_COLLECT_BOX": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
} }
}, },
"DELETE": { "DELETE": {
@ -215,6 +223,7 @@
"CONFIRM": { "CONFIRM": {
"TITLE": "Confirm Deletion", "TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ", "MESSAGE": "Are you sure to delete ",
"PLACE_HOLDER": "Please type {inboxName} to confirm",
"YES": "Yes, Delete ", "YES": "Yes, Delete ",
"NO": "No, Keep " "NO": "No, Keep "
}, },
@ -243,6 +252,8 @@
"INBOX_AGENTS": "Agents", "INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox", "INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"UPDATE": "Update", "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", "AUTO_ASSIGNMENT": "Enable auto assignment",
"INBOX_UPDATE_TITLE": "Inbox Settings", "INBOX_UPDATE_TITLE": "Inbox Settings",
"INBOX_UPDATE_SUB_TEXT": "Update your 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 _settings } from './settings.json';
import { default as _signup } from './signup.json'; import { default as _signup } from './signup.json';
import { default as _teamsSettings } from './teamsSettings.json'; import { default as _teamsSettings } from './teamsSettings.json';
import { default as _integrationApps } from './integrationApps.json';
export default { export default {
..._agentMgmt, ..._agentMgmt,
@ -34,4 +35,5 @@ export default {
..._settings, ..._settings,
..._signup, ..._signup,
..._teamsSettings, ..._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": { "LIST": {
"404": "There are no webhooks configured for this account.", "404": "There are no webhooks configured for this account.",
"TITLE": "Manage webhooks", "TITLE": "Manage webhooks",
"TABLE_HEADER": [ "TABLE_HEADER": ["Webhook endpoint", "Actions"]
"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": { "ADD": {
"CANCEL": "Cancel", "CANCEL": "Cancel",

View file

@ -9,17 +9,15 @@
"404": "There are no labels available in this account.", "404": "There are no labels available in this account.",
"TITLE": "Manage labels", "TITLE": "Manage labels",
"DESC": "Labels let you group the conversations together.", "DESC": "Labels let you group the conversations together.",
"TABLE_HEADER": [ "TABLE_HEADER": ["Name", "Description", "Color"]
"Name",
"Description",
"Color"
]
}, },
"FORM": { "FORM": {
"NAME": { "NAME": {
"LABEL": "Label Name", "LABEL": "Label Name",
"PLACEHOLDER": "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": { "DESCRIPTION": {
"LABEL": "Description", "LABEL": "Description",

View file

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

View file

@ -17,7 +17,8 @@
"TITLE": "Add agents to team - %{teamName}", "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." "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", "title": "Create",
"route": "settings_teams_new", "route": "settings_teams_new",
"body": "Create a new team of agents." "body": "Create a new team of agents."
@ -45,7 +46,8 @@
"TITLE": "Add agents to team - %{teamName}", "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." "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", "title": "Team details",
"route": "settings_teams_edit", "route": "settings_teams_edit",
"body": "Change name, description and other details." "body": "Change name, description and other details."
@ -97,6 +99,7 @@
}, },
"CONFIRM": { "CONFIRM": {
"TITLE": "Are you sure want to delete - %{teamName}", "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.", "MESSAGE": "Deleting the team will remove the team assignment from the conversations assigned to this team.",
"YES": "Delete ", "YES": "Delete ",
"NO": "Cancel" "NO": "Cancel"

View file

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

View file

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

View file

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

View file

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

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

View file

@ -9,9 +9,20 @@
{{ $t('CONVERSATION_SIDEBAR.DETAILS_TITLE') }} {{ $t('CONVERSATION_SIDEBAR.DETAILS_TITLE') }}
</h4> </h4>
<div class="multiselect-wrap--small"> <div class="multiselect-wrap--small">
<label class="multiselect__label"> <div class="self-assign">
{{ $t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL') }} <label class="multiselect__label">
</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"> <div v-on-clickaway="onCloseDropdown" class="dropdown-wrap">
<button <button
:v-model="assignedAgent" :v-model="assignedAgent"
@ -221,6 +232,7 @@ export default {
...mapGetters({ ...mapGetters({
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
teams: 'teams/getTeams', teams: 'teams/getTeams',
currentUser: 'getCurrentUser',
getAgents: 'inboxAssignableAgents/getAssignableAgents', getAgents: 'inboxAssignableAgents/getAssignableAgents',
uiFlags: 'inboxAssignableAgents/getUIFlags', 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: { watch: {
conversationId(newConversationId, prevConversationId) { conversationId(newConversationId, prevConversationId) {
@ -380,6 +401,29 @@ export default {
onCloseDropdownTeam() { onCloseDropdownTeam() {
this.showSearchDropdownTeam = false; 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> </script>
@ -534,4 +578,12 @@ export default {
} }
} }
} }
.self-assign {
display: flex;
justify-content: space-between;
.button-content {
margin-bottom: var(--space-small);
}
}
</style> </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> </template>
<script> <script>
/* global bus */
/* eslint no-console: 0 */ /* eslint no-console: 0 */
import { required, minLength } from 'vuelidate/lib/validators'; import { required, minLength } from 'vuelidate/lib/validators';

View file

@ -29,16 +29,6 @@ export default {
}, },
data() { data() {
return { 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: {}, enabledFeatures: {},
}; };
}, },
@ -46,8 +36,26 @@ export default {
account() { account() {
return this.$store.getters['accounts/getAccount'](this.accountId); 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({ ...mapGetters({
accountId: 'getCurrentAccountId', accountId: 'getCurrentAccountId',
globalConfig: 'globalConfig/get',
}), }),
}, },
mounted() { mounted() {

View file

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

View file

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

View file

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

View file

@ -207,8 +207,3 @@ export default {
}, },
}; };
</script> </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> </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