Merge branch 'release/1.17.0'

This commit is contained in:
Sojan
2021-06-15 23:21:02 +05:30
832 changed files with 19242 additions and 4151 deletions

View File

@@ -116,6 +116,10 @@ SLACK_CLIENT_SECRET=
### Change this env variable only if you are using a custom build mobile app
## Mobile app env variables
IOS_APP_ID=6C953F3RX2.com.chatwoot.app
ANDROID_BUNDLE_ID=com.chatwoot.app
# https://developers.google.com/android/guides/client-auth (use keytool to print the fingerprint in the first section)
ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:D4:5D:D4:53:F8:3B:FB:D3:C6:28:64:1D:AA:08:1E:D8
### Smart App Banner
@@ -143,6 +147,11 @@ USE_INBOX_AVATAR_FOR_BOT=true
# maxmindb api key to use geoip2 service
# IP_LOOKUP_API_KEY=
## Running chatwoot as an API only server
## setting this value to true will disable the frontend dashboard endpoints
# CW_API_ONLY_SERVER=false
## Development Only Config
# if you want to use letter_opener for local emails
# LETTER_OPENER=true

View File

@@ -44,6 +44,9 @@ Metrics/BlockLength:
- '**/routes.rb'
- 'config/environments/*'
- db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/woot_message_seeder.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -51,6 +54,7 @@ Rails/ApplicationController:
- 'app/controllers/widget_tests_controller.rb'
- 'app/controllers/widgets_controller.rb'
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
Style/ClassAndModuleChildren:
EnforcedStyle: compact
Exclude:

View File

@@ -2,6 +2,7 @@ import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import WootUiKit from '../app/javascript/dashboard/components';
import i18n from '../app/javascript/dashboard/i18n';
@@ -9,6 +10,7 @@ import i18n from '../app/javascript/dashboard/i18n';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);

View File

@@ -59,6 +59,7 @@ gem 'barnes'
##--- gems for authentication & authorization ---##
gem 'devise'
gem 'devise-secure_password', '~> 2.0'
gem 'devise_token_auth'
# authorization
gem 'jwt'
@@ -72,7 +73,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
# TODO: bump up gem to 2.0
gem 'facebook-messenger', '1.5.0'
gem 'facebook-messenger'
gem 'telegram-bot-ruby'
gem 'twilio-ruby', '~> 5.32.0'
# twitty will handle subscription of twitter account events
@@ -132,8 +133,6 @@ group :test do
end
group :development, :test do
# locking until https://github.com/codeclimate/test-reporter/issues/418 is resolved
gem 'action-cable-testing'
gem 'bundle-audit', require: false
gem 'byebug', platform: :mri
gem 'factory_bot_rails'

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!]
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
@@ -18,7 +18,8 @@ class ContactBuilder
::ContactInbox.create!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
source_id: source_id,
hmac_verified: hmac_verified || false
)
end
@@ -28,7 +29,7 @@ class ContactBuilder
def create_contact
account.contacts.create!(
name: contact_attributes[:name],
name: contact_attributes[:name] || ::Haikunator.haikunate(1000),
phone_number: contact_attributes[:phone_number],
email: contact_attributes[:email],
identifier: contact_attributes[:identifier],

View File

@@ -13,6 +13,7 @@ class Messages::Facebook::MessageBuilder
@outgoing_echo = outgoing_echo
@sender_id = (@outgoing_echo ? @response.recipient_id : @response.sender_id)
@message_type = (@outgoing_echo ? :outgoing : :incoming)
@attachments = (@response.attachments || [])
end
def perform
@@ -41,13 +42,19 @@ class Messages::Facebook::MessageBuilder
def build_message
@message = conversation.messages.create!(message_params)
(response.attachments || []).each do |attachment|
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
@attachments.each do |attachment|
process_attachment(attachment)
end
end
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
end
def attach_file(attachment, file_url)
file_resource = LocalResource.new(file_url)
attachment.file.attach(io: file_resource.file, filename: file_resource.filename, content_type: file_resource.encoding)

View File

@@ -39,7 +39,17 @@ class Messages::MessageBuilder
end
def sender
message_type == 'outgoing' ? @user : @conversation.contact
message_type == 'outgoing' ? (message_sender || @user) : @conversation.contact
end
def external_created_at
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
end
def message_sender
return if @params[:sender_type] != 'AgentBot'
AgentBot.where(account_id: [nil, @conversation.account.id]).find_by(id: @params[:sender_id])
end
def message_params
@@ -54,6 +64,6 @@ class Messages::MessageBuilder
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id]
}
}.merge(external_created_at)
end
end

View File

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

View File

@@ -16,4 +16,8 @@ class Api::BaseController < ApplicationController
authorize(model)
end
def check_admin_authorization?
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end

View File

@@ -0,0 +1,35 @@
class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :check_authorization
before_action :agent_bot, except: [:index, :create]
def index
@agent_bots = AgentBot.where(account_id: [nil, Current.account.id])
end
def show; end
def create
@agent_bot = Current.account.agent_bots.create!(permitted_params)
end
def update
@agent_bot.update!(permitted_params)
end
def destroy
@agent_bot.destroy
head :ok
end
private
def agent_bot
@agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show'
@agent_bot ||= Current.account.agent_bots.find(params[:id])
end
def permitted_params
params.permit(:name, :description, :outgoing_url)
end
end

View File

@@ -38,6 +38,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@user = User.find_by(email: new_agent_params[:email])
end
# TODO: move this to a builder and combine the save account user method into a builder
# ensure the account user association is also created in a single transaction
def create_user
return if @user
@@ -58,9 +60,10 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def new_agent_params
time = Time.now.to_i
# intial string ensures the password requirements are met
temp_password = "1!aA#{SecureRandom.alphanumeric(12)}"
params.require(:agent).permit(:email, :name, :role)
.merge!(password: time, password_confirmation: time, inviter: current_user)
.merge!(password: temp_password, password_confirmation: temp_password, inviter: current_user)
end
def agents

View File

@@ -10,6 +10,11 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
@campaign = Current.account.campaigns.create!(campaign_params)
end
def destroy
@campaign.destroy
head :ok
end
def show; end
def update

View File

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

View File

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

View File

@@ -48,7 +48,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def show; end
def contactable_inboxes
@contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
@all_contactable_inboxes = Contacts::ContactableInboxesService.new(contact: @contact).get
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
end
def create

View File

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

View File

@@ -1,7 +1,7 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types
before_action :conversation, except: [:index]
before_action :conversation, except: [:index, :meta, :search, :create]
before_action :contact_inbox, only: [:create]
def index
@@ -41,7 +41,9 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def transcript
ConversationReplyMailer.conversation_transcript(@conversation, params[:email])&.deliver_later if params[:email].present?
render json: { error: 'email param missing' }, status: :unprocessable_entity and return if params[:email].blank?
ConversationReplyMailer.with(account: @conversation.account).conversation_transcript(@conversation, params[:email])&.deliver_later
head :ok
end
@@ -77,34 +79,40 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def conversation
@conversation ||= Current.account.conversations.find_by(display_id: params[:id])
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation.inbox, :show?
end
def contact_inbox
@contact_inbox = build_contact_inbox
@contact_inbox ||= ::ContactInbox.find_by!(source_id: params[:source_id])
authorize @contact_inbox.inbox, :show?
end
def build_contact_inbox
return if params[:contact_id].blank? || params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
authorize inbox, :show?
ContactInboxBuilder.new(
contact_id: params[:contact_id],
inbox_id: params[:inbox_id],
inbox_id: inbox.id,
source_id: params[:source_id]
).perform
end
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
{
account_id: Current.account.id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes
}
}.merge(status)
end
def conversation_finder

View File

@@ -1,55 +0,0 @@
class Api::V1::Accounts::FacebookIndicatorsController < Api::V1::Accounts::BaseController
before_action :set_access_token
around_action :handle_with_exception
def mark_seen
fb_bot.deliver(payload('mark_seen'), access_token: @access_token)
head :ok
end
def typing_on
fb_bot.deliver(payload('typing_on'), access_token: @access_token)
head :ok
end
def typing_off
fb_bot.deliver(payload('typing_off'), access_token: @access_token)
head :ok
end
private
def fb_bot
::Facebook::Messenger::Bot
end
def handle_with_exception
yield
rescue Facebook::Messenger::Error => e
Rails.logger.debug "Rescued: #{e.inspect}"
true
end
def payload(action)
{
recipient: { id: contact.source_id },
sender_action: action
}
end
def inbox
@inbox ||= Current.account.inboxes.find(permitted_params[:inbox_id])
end
def set_access_token
@access_token = inbox.channel.page_access_token
end
def contact
@contact ||= inbox.contact_inboxes.find_by!(contact_id: permitted_params[:contact_id])
end
def permitted_params
params.permit(:inbox_id, :contact_id)
end
end

View File

@@ -3,15 +3,19 @@ class Api::V1::Accounts::InboxMembersController < Api::V1::Accounts::BaseControl
before_action :current_agents_ids, only: [:create]
def create
# update also done via same action
update_agents_list
head :ok
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
render_could_not_create_error('Could not add agents to inbox')
authorize @inbox, :create?
begin
# update also done via same action
update_agents_list
head :ok
rescue StandardError => e
Rails.logger.debug "Rescued: #{e.inspect}"
render_could_not_create_error('Could not add agents to inbox')
end
end
def show
authorize @inbox, :show?
@agents = Current.account.users.where(id: @inbox.members.select(:user_id))
end

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ class Api::V1::AccountsController < Api::BaseController
account_name: account_params[:account_name],
user_full_name: account_params[:user_full_name],
email: account_params[:email],
confirmed: confirmed?,
user_password: account_params[:password],
user: current_user
).perform
if @user
@@ -46,17 +46,13 @@ class Api::V1::AccountsController < Api::BaseController
private
def confirmed?
super_admin? && params[:confirmed]
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)
end
def account_params
params.permit(:account_name, :email, :name, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
params.permit(:account_name, :email, :name, :password, :locale, :domain, :support_email, :auto_resolve_duration, :user_full_name)
end
def check_signup_enabled

View File

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

View File

@@ -6,6 +6,12 @@ class Api::V1::ProfilesController < Api::BaseController
end
def update
if password_params[:password].present?
render_could_not_create_error('Invalid current password') and return unless @user.valid_password?(password_params[:current_password])
@user.update!(password_params.except(:current_password))
end
@user.update!(profile_params)
end
@@ -20,11 +26,17 @@ class Api::V1::ProfilesController < Api::BaseController
:email,
:name,
:display_name,
:password,
:password_confirmation,
:avatar,
:availability,
ui_settings: {}
)
end
def password_params
params.require(:profile).permit(
:current_password,
:password,
:password_confirmation
)
end
end

View File

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

View File

@@ -20,7 +20,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
@message.update!(message_update_params[:message])
end
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
render json: { error: @contact.errors, message: e.message }.to_json, status: :internal_server_error
end
private
@@ -58,6 +58,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
end
def permitted_params
# timestamp parameter is used in create conversation method
params.permit(:id, :before, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
end

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
send_auth_headers(@recoverable)
render partial: 'devise/auth.json', locals: { resource: @recoverable }
else
render json: { "message": 'Invalid token', "redirect_url": '/' }, status: 422
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
end
end
@@ -27,7 +27,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
end
end
protected
private
def reset_password_and_confirmation(recoverable)
recoverable.confirm unless recoverable.confirmed? # confirm if user resets password without confirming anytime before
@@ -40,7 +40,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
def build_response(message, status)
render json: {
"message": message
message: message
}, status: status
end
end

View File

@@ -0,0 +1,35 @@
class Platform::Api::V1::AgentBotsController < PlatformController
before_action :set_resource, except: [:index, :create]
before_action :validate_platform_app_permissible, except: [:index, :create]
def index
@resources = @platform_app.platform_app_permissibles.where(permissible_type: 'AgentBot').all
end
def create
@resource = AgentBot.new(agent_bot_params)
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def show; end
def update
@resource.update!(agent_bot_params)
end
def destroy
@resource.destroy!
head :ok
end
private
def set_resource
@resource = AgentBot.find(params[:id])
end
def agent_bot_params
params.permit(:name, :description, :account_id, :outgoing_url)
end
end

View File

@@ -0,0 +1,48 @@
class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesController
before_action :contact_inbox, except: [:create]
before_action :process_hmac
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactBuilder.new(
source_id: source_id,
inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier, :identifier_hash)
).perform
end
def show; end
def update
contact_identify_action = ContactIdentifyAction.new(
contact: @contact_inbox.contact,
params: permitted_params.to_h.deep_symbolize_keys.except(:identifier)
)
render json: contact_identify_action.perform
end
private
def contact_inbox
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:id])
end
def process_hmac
return if params[:identifier_hash].blank? && !@inbox_channel.hmac_mandatory
raise StandardError, 'HMAC failed: Invalid Identifier Hash Provided' unless valid_hmac?
@contact_inbox.update(hmac_verified: true) if @contact_inbox.present?
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
end
def permitted_params
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
end
end

View File

@@ -0,0 +1,24 @@
class Public::Api::V1::Inboxes::ConversationsController < Public::Api::V1::InboxesController
def index
@conversations = @contact_inbox.hmac_verified? ? @contact.conversations : @contact_inbox.conversations
end
def create
@conversation = create_conversation
end
private
def create_conversation
::Conversation.create!(conversation_params)
end
def conversation_params
{
account_id: @contact_inbox.contact.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id
}
end
end

View File

@@ -0,0 +1,68 @@
class Public::Api::V1::Inboxes::MessagesController < Public::Api::V1::InboxesController
before_action :set_message, only: [:update]
def index
@messages = @conversation.nil? ? [] : message_finder.perform
end
def create
@message = @conversation.messages.new(message_params)
@message.save
build_attachment
end
def update
@message.update!(message_update_params)
rescue StandardError => e
render json: { error: @contact.errors, message: e.message }.to_json, status: 500
end
private
def build_attachment
return if params[:attachments].blank?
params[:attachments].each do |uploaded_attachment|
attachment = @message.attachments.new(
account_id: @message.account_id,
file_type: helpers.file_type(uploaded_attachment&.content_type)
)
attachment.file.attach(uploaded_attachment)
end
@message.save!
end
def message_finder_params
{
filter_internal_messages: true,
before: params[:before]
}
end
def message_finder
@message_finder ||= MessageFinder.new(@conversation, message_finder_params)
end
def message_update_params
params.permit(submitted_values: [:name, :title, :value])
end
def permitted_params
params.permit(:content, :echo_id)
end
def set_message
@message = @conversation.messages.find(params[:id])
end
def message_params
{
account_id: @conversation.account_id,
sender: @contact_inbox.contact,
content: permitted_params[:content],
inbox_id: @conversation.inbox_id,
echo_id: permitted_params[:echo_id],
message_type: :incoming
}
end
end

View File

@@ -0,0 +1,23 @@
class Public::Api::V1::InboxesController < PublicController
before_action :set_inbox_channel
before_action :set_contact_inbox
before_action :set_conversation
private
def set_inbox_channel
@inbox_channel = ::Channel::Api.find_by!(identifier: params[:inbox_id])
end
def set_contact_inbox
return if params[:contact_id].blank?
@contact_inbox = @inbox_channel.inbox.contact_inboxes.find_by!(source_id: params[:contact_id])
end
def set_conversation
return if params[:conversation_id].blank?
@conversation = @contact_inbox.contact.conversations.find_by!(display_id: params[:conversation_id])
end
end

View File

@@ -0,0 +1,3 @@
class PublicController < ActionController::Base
skip_before_action :verify_authenticity_token
end

View File

@@ -15,8 +15,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
description: Field::String,
outgoing_url: Field::String,
created_at: Field::DateTime,
updated_at: Field::DateTime,
hide_input_for_bot_conversations: Field::Boolean
updated_at: Field::DateTime
}.freeze
# COLLECTION_ATTRIBUTES
@@ -39,7 +38,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
name
description
outgoing_url
hide_input_for_bot_conversations
].freeze
# FORM_ATTRIBUTES
@@ -49,7 +47,6 @@ class AgentBotDashboard < Administrate::BaseDashboard
name
description
outgoing_url
hide_input_for_bot_conversations
].freeze
# COLLECTION_FILTERS

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ export default {
account_name: creds.accountName.trim(),
user_full_name: creds.fullName.trim(),
email: creds.email,
password: creds.password,
})
.then(response => {
setAuthCredentials(response);
@@ -95,8 +96,18 @@ export default {
},
verifyPasswordToken({ confirmationToken }) {
return axios.post('auth/confirmation', {
confirmation_token: confirmationToken,
return new Promise((resolve, reject) => {
axios
.post('auth/confirmation', {
confirmation_token: confirmationToken,
})
.then(response => {
setAuthCredentials(response);
resolve(response);
})
.catch(error => {
reject(error.response);
});
});
},

View File

@@ -0,0 +1,9 @@
import ApiClient from './ApiClient';
class ContactNotes extends ApiClient {
constructor() {
super('contact_notes', { accountScoped: true });
}
}
export default new ContactNotes();

View File

@@ -18,6 +18,14 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
getContactLabels(contactId) {
return axios.get(`${this.url}/${contactId}/labels`);
}
updateContactLabels(contactId, labels) {
return axios.post(`${this.url}/${contactId}/labels`, { labels });
}
search(search = '', page = 1, sortAttr = 'name') {
return axios.get(
`${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}`

View File

@@ -16,6 +16,14 @@ class IntegrationsAPI extends ApiClient {
delete(integrationId) {
return axios.delete(`${this.baseUrl()}/integrations/${integrationId}`);
}
createHook(hookData) {
return axios.post(`${this.baseUrl()}/integrations/hooks`, hookData);
}
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
}
export default new IntegrationsAPI();

View File

@@ -0,0 +1,26 @@
import accountAPI from '../account';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#accountAPI', () => {
it('creates correct instance', () => {
expect(accountAPI).toBeInstanceOf(ApiClient);
expect(accountAPI).toHaveProperty('get');
expect(accountAPI).toHaveProperty('show');
expect(accountAPI).toHaveProperty('create');
expect(accountAPI).toHaveProperty('update');
expect(accountAPI).toHaveProperty('delete');
expect(accountAPI).toHaveProperty('createAccount');
});
describeWithAPIMock('API calls', context => {
it('#createAccount', () => {
accountAPI.createAccount({
name: 'Chatwoot',
});
expect(context.axiosMock.post).toHaveBeenCalledWith('/api/v1/accounts', {
name: 'Chatwoot',
});
});
});
});

View File

@@ -0,0 +1,27 @@
function apiSpecHelper() {
beforeEach(() => {
this.originalAxios = window.axios;
this.axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
window.axios = this.axiosMock;
});
afterEach(() => {
window.axios = this.originalAxios;
});
}
// https://stackoverflow.com/a/59344023/3901856
const sharedWrapper = describe('sharedWrapper', () => {});
export default function describeWithAPIMock(skillName, testFn) {
return describe(skillName, function configureContext() {
function Context() {}
Context.prototype = sharedWrapper.ctx;
this.ctx = new Context();
apiSpecHelper.call(this);
testFn.call(this, this);
});
}

View File

@@ -1,5 +1,6 @@
import fbChannel from '../../channel/fbChannel';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#FBChannel', () => {
it('creates correct instance', () => {
@@ -10,4 +11,29 @@ describe('#FBChannel', () => {
expect(fbChannel).toHaveProperty('update');
expect(fbChannel).toHaveProperty('delete');
});
describeWithAPIMock('API calls', context => {
it('#create', () => {
fbChannel.create({ omniauthToken: 'ASFM131CSF@#@$', appId: 'chatwoot' });
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/callbacks/register_facebook_page',
{
omniauthToken: 'ASFM131CSF@#@$',
appId: 'chatwoot',
}
);
});
it('#reauthorize', () => {
fbChannel.reauthorizeFacebookPage({
omniauthToken: 'ASFM131CSF@#@$',
inboxId: 1,
});
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/callbacks/reauthorize_page',
{
omniauth_token: 'ASFM131CSF@#@$',
inbox_id: 1,
}
);
});
});
});

View File

@@ -0,0 +1,13 @@
import twilioChannel from '../../channel/twilioChannel';
import ApiClient from '../../ApiClient';
describe('#twilioChannel', () => {
it('creates correct instance', () => {
expect(twilioChannel).toBeInstanceOf(ApiClient);
expect(twilioChannel).toHaveProperty('get');
expect(twilioChannel).toHaveProperty('show');
expect(twilioChannel).toHaveProperty('create');
expect(twilioChannel).toHaveProperty('update');
expect(twilioChannel).toHaveProperty('delete');
});
});

View File

@@ -1,9 +1,14 @@
import TwitterClient from '../../channel/twitterClient';
import twitterClient from '../../channel/twitterClient';
import ApiClient from '../../ApiClient';
describe('#TwitterClient', () => {
it('creates correct instance', () => {
expect(TwitterClient).toBeInstanceOf(ApiClient);
expect(TwitterClient).toHaveProperty('generateAuthorization');
expect(twitterClient).toBeInstanceOf(ApiClient);
expect(twitterClient).toHaveProperty('get');
expect(twitterClient).toHaveProperty('show');
expect(twitterClient).toHaveProperty('create');
expect(twitterClient).toHaveProperty('update');
expect(twitterClient).toHaveProperty('delete');
expect(twitterClient).toHaveProperty('generateAuthorization');
});
});

View File

@@ -0,0 +1,13 @@
import webChannelClient from '../../channel/webChannel';
import ApiClient from '../../ApiClient';
describe('#webChannelClient', () => {
it('creates correct instance', () => {
expect(webChannelClient).toBeInstanceOf(ApiClient);
expect(webChannelClient).toHaveProperty('get');
expect(webChannelClient).toHaveProperty('show');
expect(webChannelClient).toHaveProperty('create');
expect(webChannelClient).toHaveProperty('update');
expect(webChannelClient).toHaveProperty('delete');
});
});

View File

@@ -1,14 +1,63 @@
import contacts from '../contacts';
import contactAPI from '../contacts';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => {
it('creates correct instance', () => {
expect(contacts).toBeInstanceOf(ApiClient);
expect(contacts).toHaveProperty('get');
expect(contacts).toHaveProperty('show');
expect(contacts).toHaveProperty('create');
expect(contacts).toHaveProperty('update');
expect(contacts).toHaveProperty('delete');
expect(contacts).toHaveProperty('getConversations');
expect(contactAPI).toBeInstanceOf(ApiClient);
expect(contactAPI).toHaveProperty('get');
expect(contactAPI).toHaveProperty('show');
expect(contactAPI).toHaveProperty('create');
expect(contactAPI).toHaveProperty('update');
expect(contactAPI).toHaveProperty('delete');
expect(contactAPI).toHaveProperty('getConversations');
});
describeWithAPIMock('API calls', context => {
it('#get', () => {
contactAPI.get(1, 'name');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts?page=1&sort=name'
);
});
it('#getConversations', () => {
contactAPI.getConversations(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/conversations'
);
});
it('#getContactableInboxes', () => {
contactAPI.getContactableInboxes(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/contactable_inboxes'
);
});
it('#getContactLabels', () => {
contactAPI.getContactLabels(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/1/labels'
);
});
it('#updateContactLabels', () => {
const labels = ['support-query'];
contactAPI.updateContactLabels(1, labels);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/1/labels',
{
labels,
}
);
});
it('#search', () => {
contactAPI.search('leads', 1, 'date');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/contacts/search?q=leads&page=1&sort=date'
);
});
});
});

View File

@@ -1,15 +1,36 @@
import conversations from '../conversations';
import conversationsAPI from '../conversations';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ConversationApi', () => {
it('creates correct instance', () => {
expect(conversations).toBeInstanceOf(ApiClient);
expect(conversations).toHaveProperty('get');
expect(conversations).toHaveProperty('show');
expect(conversations).toHaveProperty('create');
expect(conversations).toHaveProperty('update');
expect(conversations).toHaveProperty('delete');
expect(conversations).toHaveProperty('getLabels');
expect(conversations).toHaveProperty('updateLabels');
expect(conversationsAPI).toBeInstanceOf(ApiClient);
expect(conversationsAPI).toHaveProperty('get');
expect(conversationsAPI).toHaveProperty('show');
expect(conversationsAPI).toHaveProperty('create');
expect(conversationsAPI).toHaveProperty('update');
expect(conversationsAPI).toHaveProperty('delete');
expect(conversationsAPI).toHaveProperty('getLabels');
expect(conversationsAPI).toHaveProperty('updateLabels');
});
describeWithAPIMock('API calls', context => {
it('#getLabels', () => {
conversationsAPI.getLabels(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/1/labels'
);
});
it('#updateLabels', () => {
const labels = ['support-query'];
conversationsAPI.updateLabels(1, labels);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/1/labels',
{
labels,
}
);
});
});
});

View File

@@ -0,0 +1,13 @@
import endPoints from '../endPoints';
describe('#endPoints', () => {
it('it should return register url details if register page passed ', () => {
expect(endPoints('register')).toEqual({ url: 'api/v1/accounts.json' });
});
it('it should inbox url details if getInbox page passed', () => {
expect(endPoints('getInbox')).toEqual({
url: 'api/v1/conversations.json',
params: { inbox_id: null },
});
});
});

View File

@@ -1,5 +1,6 @@
import conversationAPI from '../../inbox/conversation';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#ConversationAPI', () => {
it('creates correct instance', () => {
@@ -20,27 +21,143 @@ describe('#ConversationAPI', () => {
expect(conversationAPI).toHaveProperty('sendEmailTranscript');
});
describe('API calls', () => {
let originalAxios = null;
let axiosMock = null;
beforeEach(() => {
originalAxios = window.axios;
axiosMock = { post: jest.fn(() => Promise.resolve()) };
window.axios = axiosMock;
describeWithAPIMock('API calls', context => {
it('#get conversations', () => {
conversationAPI.get({
inboxId: 1,
status: 'open',
assigneeType: 'me',
page: 1,
labels: [],
teamId: 1,
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations',
{
params: {
inbox_id: 1,
team_id: 1,
status: 'open',
assignee_type: 'me',
page: 1,
labels: [],
},
}
);
});
afterEach(() => {
window.axios = originalAxios;
it('#search', () => {
conversationAPI.search({
q: 'leads',
page: 1,
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/search',
{
params: {
q: 'leads',
page: 1,
},
}
);
});
it('#toggleStatus', () => {
conversationAPI.toggleStatus({ conversationId: 12, status: 'online' });
expect(context.axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/toggle_status`,
{
status: 'online',
}
);
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(context.axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments?assignee_id=34`,
{}
);
});
it('#assignTeam', () => {
conversationAPI.assignTeam({ conversationId: 12, teamId: 1 });
expect(context.axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
team_id: 1,
}
);
});
it('#markMessageRead', () => {
conversationAPI.markMessageRead({ id: 12 });
expect(context.axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/update_last_seen`
);
});
it('#toggleTyping', () => {
conversationAPI.toggleTyping({
conversationId: 12,
status: 'typing_on',
});
expect(context.axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/toggle_typing_status`,
{
typing_status: 'typing_on',
}
);
});
it('#mute', () => {
conversationAPI.mute(45);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/mute'
);
});
it('#unmute', () => {
conversationAPI.unmute(45);
expect(axiosMock.post).toHaveBeenCalledWith(
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/unmute'
);
});
it('#meta', () => {
conversationAPI.meta({
inboxId: 1,
status: 'open',
assigneeType: 'me',
labels: [],
teamId: 1,
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/conversations/meta',
{
params: {
inbox_id: 1,
team_id: 1,
status: 'open',
assignee_type: 'me',
labels: [],
},
}
);
});
it('#sendEmailTranscript', () => {
conversationAPI.sendEmailTranscript({
conversationId: 45,
email: 'john@acme.inc',
});
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/transcript',
{
email: 'john@acme.inc',
}
);
});
});
});

View File

@@ -0,0 +1,32 @@
import messageAPI from '../../inbox/message';
import ApiClient from '../../ApiClient';
import describeWithAPIMock from '../apiSpecHelper';
describe('#ConversationAPI', () => {
it('creates correct instance', () => {
expect(messageAPI).toBeInstanceOf(ApiClient);
expect(messageAPI).toHaveProperty('get');
expect(messageAPI).toHaveProperty('show');
expect(messageAPI).toHaveProperty('create');
expect(messageAPI).toHaveProperty('update');
expect(messageAPI).toHaveProperty('delete');
expect(messageAPI).toHaveProperty('getPreviousMessages');
});
describeWithAPIMock('API calls', context => {
it('#getPreviousMessages', () => {
messageAPI.getPreviousMessages({
conversationId: 12,
before: 4573,
});
expect(context.axiosMock.get).toHaveBeenCalledWith(
`/api/v1/conversations/12/messages`,
{
params: {
before: 4573,
},
}
);
});
});
});

View File

@@ -1,13 +1,31 @@
import inboxes from '../inboxes';
import inboxesAPI from '../inboxes';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#InboxesAPI', () => {
it('creates correct instance', () => {
expect(inboxes).toBeInstanceOf(ApiClient);
expect(inboxes).toHaveProperty('get');
expect(inboxes).toHaveProperty('show');
expect(inboxes).toHaveProperty('create');
expect(inboxes).toHaveProperty('update');
expect(inboxes).toHaveProperty('delete');
expect(inboxesAPI).toBeInstanceOf(ApiClient);
expect(inboxesAPI).toHaveProperty('get');
expect(inboxesAPI).toHaveProperty('show');
expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableAgents');
expect(inboxesAPI).toHaveProperty('getCampaigns');
});
describeWithAPIMock('API calls', context => {
it('#getAssignableAgents', () => {
inboxesAPI.getAssignableAgents(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/1/assignable_agents'
);
});
it('#getCampaigns', () => {
inboxesAPI.getCampaigns(2);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/2/campaigns'
);
});
});
});

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

@@ -1,13 +1,54 @@
import notifications from '../notifications';
import notificationsAPI from '../notifications';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#NotificationAPI', () => {
it('creates correct instance', () => {
expect(notifications).toBeInstanceOf(ApiClient);
expect(notifications).toHaveProperty('get');
expect(notifications).toHaveProperty('getNotifications');
expect(notifications).toHaveProperty('getUnreadCount');
expect(notifications).toHaveProperty('read');
expect(notifications).toHaveProperty('readAll');
expect(notificationsAPI).toBeInstanceOf(ApiClient);
expect(notificationsAPI).toHaveProperty('get');
expect(notificationsAPI).toHaveProperty('getNotifications');
expect(notificationsAPI).toHaveProperty('getUnreadCount');
expect(notificationsAPI).toHaveProperty('read');
expect(notificationsAPI).toHaveProperty('readAll');
});
describeWithAPIMock('API calls', context => {
it('#get', () => {
notificationsAPI.get(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/notifications?page=1'
);
});
it('#getNotifications', () => {
notificationsAPI.getNotifications(1);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/notifications/1/notifications'
);
});
it('#getUnreadCount', () => {
notificationsAPI.getUnreadCount();
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/notifications/unread_count'
);
});
it('#read', () => {
notificationsAPI.read(48670, 'Conversation');
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/notifications/read_all',
{
primary_actor_id: 'Conversation',
primary_actor_type: 48670,
}
);
});
it('#readAll', () => {
notificationsAPI.readAll();
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/notifications/read_all'
);
});
});
});

View File

@@ -1,17 +1,63 @@
import reports from '../reports';
import reportsAPI from '../reports';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#Reports API', () => {
it('creates correct instance', () => {
expect(reports).toBeInstanceOf(ApiClient);
expect(reports.apiVersion).toBe('/api/v2');
expect(reports).toHaveProperty('get');
expect(reports).toHaveProperty('show');
expect(reports).toHaveProperty('create');
expect(reports).toHaveProperty('update');
expect(reports).toHaveProperty('delete');
expect(reports).toHaveProperty('getAccountReports');
expect(reports).toHaveProperty('getAccountSummary');
expect(reports).toHaveProperty('getAgentReports');
expect(reportsAPI).toBeInstanceOf(ApiClient);
expect(reportsAPI.apiVersion).toBe('/api/v2');
expect(reportsAPI).toHaveProperty('get');
expect(reportsAPI).toHaveProperty('show');
expect(reportsAPI).toHaveProperty('create');
expect(reportsAPI).toHaveProperty('update');
expect(reportsAPI).toHaveProperty('delete');
expect(reportsAPI).toHaveProperty('getAccountReports');
expect(reportsAPI).toHaveProperty('getAccountSummary');
expect(reportsAPI).toHaveProperty('getAgentReports');
});
describeWithAPIMock('API calls', context => {
it('#getAccountReports', () => {
reportsAPI.getAccountReports(
'conversations_count',
1621103400,
1621621800
);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/account',
{
params: {
metric: 'conversations_count',
since: 1621103400,
until: 1621621800,
},
}
);
});
it('#getAccountSummary', () => {
reportsAPI.getAccountSummary(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/account_summary',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
it('#getAgentReports', () => {
reportsAPI.getAgentReports(1621103400, 1621621800);
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/agents',
{
params: {
since: 1621103400,
until: 1621621800,
},
}
);
});
});
});

View File

@@ -1,16 +1,49 @@
import teams from '../teams';
import teamsAPI from '../teams';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#TeamsAPI', () => {
it('creates correct instance', () => {
expect(teams).toBeInstanceOf(ApiClient);
expect(teams).toHaveProperty('get');
expect(teams).toHaveProperty('show');
expect(teams).toHaveProperty('create');
expect(teams).toHaveProperty('update');
expect(teams).toHaveProperty('delete');
expect(teams).toHaveProperty('getAgents');
expect(teams).toHaveProperty('addAgents');
expect(teams).toHaveProperty('updateAgents');
expect(teamsAPI).toBeInstanceOf(ApiClient);
expect(teamsAPI).toHaveProperty('get');
expect(teamsAPI).toHaveProperty('show');
expect(teamsAPI).toHaveProperty('create');
expect(teamsAPI).toHaveProperty('update');
expect(teamsAPI).toHaveProperty('delete');
expect(teamsAPI).toHaveProperty('getAgents');
expect(teamsAPI).toHaveProperty('addAgents');
expect(teamsAPI).toHaveProperty('updateAgents');
});
describeWithAPIMock('API calls', context => {
it('#getAgents', () => {
teamsAPI.getAgents({ teamId: 1 });
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v1/teams/1/team_members'
);
});
it('#addAgents', () => {
teamsAPI.addAgents({ teamId: 1, agentsList: { user_ids: [1, 10, 21] } });
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/teams/1/team_members',
{
user_ids: { user_ids: [1, 10, 21] },
}
);
});
it('#updateAgents', () => {
const agentsList = { user_ids: [1, 10, 21] };
teamsAPI.updateAgents({
teamId: 1,
agentsList,
});
expect(context.axiosMock.patch).toHaveBeenCalledWith(
'/api/v1/teams/1/team_members',
{
user_ids: agentsList,
}
);
});
});
});

View File

@@ -0,0 +1,13 @@
import webhooksAPI from '../webhooks';
import ApiClient from '../ApiClient';
describe('#webhooksAPI', () => {
it('creates correct instance', () => {
expect(webhooksAPI).toBeInstanceOf(ApiClient);
expect(webhooksAPI).toHaveProperty('get');
expect(webhooksAPI).toHaveProperty('show');
expect(webhooksAPI).toHaveProperty('create');
expect(webhooksAPI).toHaveProperty('update');
expect(webhooksAPI).toHaveProperty('delete');
});
});

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

@@ -5,14 +5,15 @@
&.round {
border-radius: 1000px;
}
}
&.grey-btn {
color: $color-gray;
.card {
margin-bottom: var(--space-small);
padding: var(--space-small);
}
&:hover {
color: $color-light-gray;
}
}
.button-wrapper .button.link.grey-btn {
margin-left: var(--space-normal);
}
.tooltip {

View File

@@ -131,7 +131,7 @@ $header-text-rendering: optimizeLegibility;
$small-font-size: 80%;
$header-small-font-color: $medium-gray;
$paragraph-lineheight: 1.45;
$paragraph-margin-bottom: 1rem;
$paragraph-margin-bottom: var(--space-small);
$paragraph-text-rendering: optimizeLegibility;
$code-color: $black;
$code-font-family: $font-family-monospace;
@@ -238,7 +238,7 @@ $breadcrumbs-item-slash: true;
// 11. Button
// ----------
$button-padding: var(--space-one) var(--space-slab);
$button-padding: var(--space-smaller) 1em;
$button-margin: 0 0 $global-margin 0;
$button-fill: solid;
$button-background: $primary-color;
@@ -246,7 +246,7 @@ $button-background-hover: scale-color($button-background, $lightness: -15%);
$button-color: $white;
$button-color-alt: $white;
$button-radius: var(--border-radius-normal);
$button-sizes: (tiny: var(--font-size-nano),
$button-sizes: (tiny: var(--font-size-micro),
small: var(--font-size-mini),
default: var(--font-size-small),
large: var(--font-size-medium));
@@ -285,10 +285,10 @@ $callout-link-tint: 30%;
$card-background: $white;
$card-font-color: $body-font-color;
$card-divider-background: $light-gray;
$card-border: 1px solid $light-gray;
$card-shadow: none;
$card-border-radius: $global-radius;
$card-padding: $global-padding;
$card-border: 1px solid var(--color-border);
$card-shadow: var(--shadow-small);
$card-border-radius: var(--border-radius-normal);
$card-padding: var(--space-small);
$card-margin: $global-margin;
// 15. Close Button
@@ -345,21 +345,21 @@ $fieldset-padding: $space-two;
$fieldset-margin: $space-one $zero;
$legend-padding: rem-calc(0 3);
$form-spacing: $space-normal;
$helptext-color: $header-color;
$helptext-color: $color-body;
$helptext-font-size: $font-size-small;
$helptext-font-style: italic;
$input-prefix-color: $header-color;
$input-prefix-color: $color-body;
$input-prefix-background: var(--b-100);
$input-prefix-border: 1px solid $color-border;
$input-prefix-padding: 1rem;
$form-label-color: $header-color;
$form-label-color: $color-body;
$form-label-font-size: rem-calc(14);
$form-label-font-weight: $font-weight-medium;
$form-label-line-height: 1.8;
$select-background: $white;
$select-triangle-color: $dark-gray;
$select-radius: var(--border-radius-normal);
$input-color: $header-color;
$input-color: $color-body;
$input-placeholder-color: $light-gray;
$input-font-family: inherit;
$input-font-size: $font-size-default;

View File

@@ -19,7 +19,7 @@
}
.text-muted {
color: $color-gray;
color: var(--s-300);
}
a {

View File

@@ -1,8 +1,28 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import 'mixins';
@import 'foundation-settings';
@import 'helper-classes';
@import 'formulate';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';
@include foundation-everything($flex: true);
@import 'typography';
@import 'layout';
@import 'animations';
@import 'foundation-custom';
@import 'widgets/buttons';
@import 'widgets/conv-header';
@import 'widgets/conversation-card';
@@ -26,4 +46,4 @@
@import 'plugins/multiselect';
@import 'plugins/dropdown';
@import '@chatwoot/prosemirror-schema/src/woot-editor.css';
@import '~shared/assets/stylesheets/ionicons';

View File

@@ -1,20 +1 @@
@import 'shared/assets/fonts/inter';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'variables';
@import '~spinkit/scss/spinners/7-three-bounce';
@import '~shared/assets/stylesheets/ionicons';
@import 'mixins';
@import 'foundation-settings';
@import 'helper-classes';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';
@include foundation-everything($flex: true);
@import 'woot';

View File

@@ -14,6 +14,8 @@
@import 'foundation-settings';
@import 'helper-classes';
@import 'foundation-sites/scss/foundation';
@include foundation-prototype-spacing;
@import '~bourbon/core/bourbon';
@include foundation-everything($flex: true);

View File

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

View File

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

View File

@@ -1,70 +1,109 @@
$default-button-height: 4.0rem;
.button {
align-items: center;
display: inline-flex;
height: $default-button-height;
margin-bottom: 0;
&.button--emoji {
align-items: center;
background: var(--b-50);
border: 1px solid var(--color-border-light);
border-radius: var(--border-radius-large);
.spinner {
padding: 0 var(--space-small);
}
.icon--emoji+.button__content {
padding-left: var(--space-small);
}
.icon--font+.button__content {
padding-left: var(--space-small);
}
// @TODDO - Remove after moving all buttons to woot-button
.icon+.button__content {
padding-left: var(--space-small);
}
&.expanded {
display: flex;
font-size: var(--font-size-small);
height: var(--space-large);
justify-content: center;
padding: var(--space-micro);
text-align: center;
width: var(--space-large);
&:hover {
background: var(--b-200);
}
}
&.icon {
padding-left: $space-normal;
padding-right: $space-normal;
i {
padding-right: $space-small;
}
}
&.nice {
border-radius: $space-smaller;
}
&.hollow {
&.link {
border-color: transparent;
padding-left: 0;
&:hover,
&:focus {
border-color: transparent;
}
}
}
>.icon {
font-size: $font-size-default;
}
&.tiny {
font-size: $font-size-mini;
padding: $space-small $space-slab;
}
&.round {
border-radius: $space-larger;
}
// @TODO Use with link
&.compact {
padding-bottom: 0;
padding-top: 0;
}
// Smooth style
&.smooth {
@include button-style(var(--w-50), var(--w-100), var(--w-700));
&.secondary {
@include button-style(var(--s-50), var(--s-100), var(--s-700));
}
&.success {
@include button-style(var(--g-50), var(--g-100), var(--g-700));
}
&.alert {
@include button-style(var(--r-50), var(--r-100), var(--r-700));
}
&.warning {
@include button-style(var(--y-100), var(--y-200), var(--y-900));
}
}
// Sizes
&.tiny {
height: var(--space-medium);
}
&.small {
height: var(--space-large);
}
&.large {
height: var(--space-larger);
}
&.button--only-icon {
justify-content: center;
padding-left: 0;
padding-right: 0;
width: $default-button-height;
&.tiny {
width: var(--space-medium);
}
&.small {
width: var(--space-large);
}
&.large {
width: var(--space-larger);
}
}
&.link {
height: auto;
margin: 0;
padding: 0;
}
}
// @TDOD move to utility file
.button--fixed-right-top {
position: fixed;
right: $space-small;

View File

@@ -2,11 +2,11 @@ $resolve-button-width: 13.2rem;
// Conversation header - Light BG
.conv-header {
@include padding($space-small $space-normal);
@include background-white;
@include flex;
@include flex-align($x: justify, $y: middle);
@include border-normal-bottom;
padding: var(--space-small) var(--space-normal);
.multiselect-box {
@include flex;
@@ -70,6 +70,7 @@ $resolve-button-width: 13.2rem;
.header-actions-wrap {
align-items: center;
display: flex;
flex-direction: row;
flex-grow: 1;

View File

@@ -76,7 +76,6 @@
.status--filter {
@include padding($zero null $zero $space-normal);
@include round-corner;
@include margin($space-smaller $space-slab $zero $zero);
background-color: $color-background-light;
border: 1px solid $color-border;

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

@@ -32,13 +32,14 @@
<span class="spinner"></span>
</div>
<div
<woot-button
v-if="!hasCurrentPageEndReached && !chatListLoading"
class="clear button load-more-conversations"
variant="clear"
size="expanded"
@click="fetchConversations"
>
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</div>
</woot-button>
<p
v-if="
@@ -217,7 +218,7 @@ export default {
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/app.scss';
@import '~dashboard/assets/scss/woot';
.spinner {
margin-top: var(--space-normal);
margin-bottom: var(--space-normal);

View File

@@ -6,6 +6,7 @@
class-names="resolve"
color-scheme="success"
icon="ion-checkmark"
emoji="✅"
:is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.RESOLVED)"
>
@@ -16,6 +17,7 @@
class-names="resolve"
color-scheme="warning"
icon="ion-refresh"
emoji="👀"
:is-loading="isLoading"
@click="() => toggleStatus(STATUS_TYPE.OPEN)"
>
@@ -36,9 +38,9 @@
:color-scheme="buttonClass"
:disabled="isLoading"
icon="ion-arrow-down-b"
emoji="🔽"
@click="openDropdown"
>
</woot-button>
/>
</div>
<div
v-if="showDropdown"

View File

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

View File

@@ -22,6 +22,8 @@
>
<woot-button
variant="clear"
size="small"
color-scheme="secondary"
class-names="status-change--dropdown-button"
:is-disabled="status.disabled"
@click="changeAvailabilityStatus(status.value)"

View File

@@ -264,16 +264,6 @@ export default {
.modal-container {
width: 40rem;
}
.page-top-bar {
padding-bottom: $space-two;
}
}
.change-accounts--button.button {
font-weight: $font-weight-normal;
font-size: $font-size-small;
padding: $space-small $space-one;
}
.account-selector {

View File

@@ -7,30 +7,42 @@
>
<woot-dropdown-menu>
<woot-dropdown-item v-if="showChangeAccountOption">
<button
class="button clear change-accounts--button"
<woot-button
variant="clear"
size="small"
class=" change-accounts--button"
@click="$emit('toggle-accounts')"
>
{{ $t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS') }}
</button>
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item v-if="globalConfig.chatwootInboxToken">
<button
class="button clear change-accounts--button"
<woot-button
variant="clear"
size="small"
class=" change-accounts--button"
@click="$emit('show-support-chat-window')"
>
Contact Support
</button>
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<router-link :to="`/app/accounts/${accountId}/profile/settings`">
<router-link
:to="`/app/accounts/${accountId}/profile/settings`"
class="button clear small change-accounts--button"
>
{{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }}
</router-link>
</woot-dropdown-item>
<woot-dropdown-item>
<a href="#" @click.prevent="logout">
<woot-button
variant="clear"
size="small"
class=" change-accounts--button"
@click="logout"
>
{{ $t('SIDEBAR_ITEMS.LOGOUT') }}
</a>
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>

View File

@@ -0,0 +1,90 @@
import AccountSelector from '../AccountSelector';
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import i18n from 'dashboard/i18n';
import WootModal from 'dashboard/components/Modal';
import WootModalHeader from 'dashboard/components/ModalHeader';
const localVue = createLocalVue();
localVue.component('woot-modal', WootModal);
localVue.component('woot-modal-header', WootModalHeader);
localVue.use(Vuex);
localVue.use(VueI18n);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
describe('accountSelctor', () => {
let accountSelector = null;
const currentUser = {
accounts: [
{
id: 1,
name: 'Chatwoot',
role: 'administrator',
},
{
id: 2,
name: 'GitX',
role: 'agent',
},
],
};
const accountId = 1;
const globalConfig = { createNewAccountFromDashboard: false };
let store = null;
let actions = null;
let modules = null;
beforeEach(() => {
actions = {};
modules = {
auth: {
getters: {
getCurrentAccountId: () => accountId,
getCurrentUser: () => currentUser,
},
},
globalConfig: {
getters: {
'globalConfig/get': () => globalConfig,
},
},
};
store = new Vuex.Store({
actions,
modules,
});
accountSelector = mount(AccountSelector, {
store,
localVue,
i18n: i18nConfig,
propsData: {
showAccountModal: true,
},
});
});
it('title and sub title exist', () => {
const headerComponent = accountSelector.findComponent(WootModalHeader);
const topBar = headerComponent.find('.page-top-bar');
const titleComponent = topBar.find('.page-sub-title');
expect(titleComponent.text()).toBe('Switch Account');
const subTitleComponent = topBar.find('p');
expect(subTitleComponent.text()).toBe(
'Select an account from the following list'
);
});
it('first account item is checked', () => {
const accountFirstItem = accountSelector.find('.account-selector .ion');
expect(accountFirstItem.exists()).toBe(true);
});
});

View File

@@ -0,0 +1,64 @@
import AgentDetails from '../AgentDetails';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import i18n from 'dashboard/i18n';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('thumbnail', Thumbnail);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
describe('agentDetails', () => {
const currentUser = { name: 'Neymar Junior', avatar_url: '' };
const currentRole = 'agent';
let store = null;
let actions = null;
let modules = null;
let agentDetails = null;
beforeEach(() => {
actions = {};
modules = {
auth: {
getters: {
getCurrentUser: () => currentUser,
getCurrentRole: () => currentRole,
},
},
};
store = new Vuex.Store({
actions,
modules,
});
agentDetails = shallowMount(AgentDetails, {
store,
localVue,
i18n: i18nConfig,
});
});
it('shows the agent name', () => {
const agentTitle = agentDetails.find('.current-user--name');
expect(agentTitle.text()).toBe('Neymar Junior');
});
it('shows the agent role', () => {
const agentTitle = agentDetails.find('.current-user--role');
expect(agentTitle.text()).toBe('Agent');
});
it('agent thumbnail exists', () => {
const thumbnailComponent = agentDetails.findComponent(Thumbnail);
expect(thumbnailComponent.exists()).toBe(true);
});
});

View File

@@ -0,0 +1,67 @@
import NotificationBell from '../NotificationBell';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import i18n from 'dashboard/i18n';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
const i18nConfig = new VueI18n({
locale: 'en',
messages: i18n,
});
describe('notificationBell', () => {
const accountId = 1;
const notificationMetadata = { unreadCount: 19 };
let store = null;
let actions = null;
let modules = null;
beforeEach(() => {
actions = {
showNotification: jest.fn(),
};
modules = {
auth: {
getters: {
getCurrentAccountId: () => accountId,
},
},
notifications: {
getters: {
'notifications/getMeta': () => notificationMetadata,
},
},
};
store = new Vuex.Store({
actions,
modules,
});
});
it('it should return unread count 19 ', () => {
const notificationBell = shallowMount(NotificationBell, {
store,
localVue,
i18n: i18nConfig,
});
const statusViewTitle = notificationBell.find('.unread-badge');
expect(statusViewTitle.text()).toBe('19');
});
it('it should return unread count 99+ ', async () => {
notificationMetadata.unreadCount = 101;
const notificationBell = shallowMount(NotificationBell, {
store,
localVue,
i18n: i18nConfig,
});
const statusViewTitle = notificationBell.find('.unread-badge');
expect(statusViewTitle.text()).toBe('99+');
});
});

View File

@@ -7,7 +7,7 @@
</div>
</template>
<script>
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
props: {
title: {
@@ -52,7 +52,11 @@ export default {
},
labelStyle() {
if (this.bgColor) {
return { background: this.bgColor, color: this.textColor };
return {
background: this.bgColor,
color: this.textColor,
border: `1px solid ${this.bgColor}`,
};
}
return {};
},

View File

@@ -1,27 +1,27 @@
<template>
<button
class="button"
:class="[
variant,
size,
colorScheme,
classNames,
isDisabled ? 'disabled' : '',
]"
:class="buttonClasses"
:disabled="isDisabled || isLoading"
@click="handleClick"
>
<spinner v-if="isLoading" size="small" />
<i v-else-if="icon" class="icon" :class="icon"></i>
<emoji-or-icon
v-else-if="icon || emoji"
class="icon"
:emoji="emoji"
:icon="icon"
/>
<span v-if="$slots.default" class="button__content"><slot></slot></span>
</button>
</template>
<script>
import Spinner from 'shared/components/Spinner.vue';
import Spinner from 'shared/components/Spinner';
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
export default {
name: 'WootButton',
components: { Spinner },
components: { EmojiOrIcon, Spinner },
props: {
variant: {
type: String,
@@ -35,12 +35,16 @@ export default {
type: String,
default: '',
},
emoji: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: 'primary',
},
classNames: {
type: String,
type: [String, Object],
default: '',
},
isDisabled: {
@@ -51,6 +55,34 @@ export default {
type: Boolean,
default: false,
},
isExpanded: {
type: Boolean,
default: false,
},
},
computed: {
variantClasses() {
if (this.variant.includes('link')) {
return `clear ${this.variant}`;
}
return this.variant;
},
hasOnlyIconClasses() {
const hasEmojiOrIcon = this.emoji || this.icon;
if (!this.$slots.default && hasEmojiOrIcon) return 'button--only-icon';
return '';
},
buttonClasses() {
return [
this.variantClasses,
this.hasOnlyIconClasses,
this.size,
this.colorScheme,
this.classNames,
this.isDisabled ? 'disabled' : '',
this.isExpanded ? 'expanded' : '',
];
},
},
methods: {
handleClick(evt) {
@@ -59,20 +91,3 @@ export default {
},
};
</script>
<style lang="scss" scoped>
.button {
display: flex;
align-items: center;
&.link {
padding: 0;
margin: 0;
}
}
.spinner {
padding: 0 var(--space-small);
}
.icon + .button__content {
padding-left: var(--space-small);
}
</style>

View File

@@ -1,49 +0,0 @@
<template>
<button :type="type" class="button nice" :class="variant" @click="onClick">
<i
v-if="!isLoading && icon"
class="icon"
:class="buttonIconClass + ' ' + icon"
/>
<spinner v-if="isLoading" />
<slot></slot>
</button>
</template>
<script>
export default {
props: {
isLoading: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
buttonIconClass: {
type: String,
default: '',
},
type: {
type: String,
default: 'button',
},
variant: {
type: String,
default: 'primary',
},
},
methods: {
onClick(e) {
this.$emit('click', e);
},
},
};
</script>
<style scoped>
.icon {
font-size: var(--font-size-large) !important;
}
</style>

View File

@@ -17,9 +17,13 @@
src="~dashboard/assets/images/channels/telegram.png"
/>
<img
v-if="channel.key === 'api'"
v-if="channel.key === 'api' && !channel.thumbnail"
src="~dashboard/assets/images/channels/api.png"
/>
<img
v-if="channel.key === 'api' && channel.thumbnail"
:src="channel.thumbnail"
/>
<img
v-if="channel.key === 'email'"
src="~dashboard/assets/images/channels/email.png"

View File

@@ -1,21 +1,23 @@
<template>
<div class="inbox-item" >
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image"/>
<div class="inbox-item">
<img src="~dashboard/assets/images/no_page_image.png" alt="No Page Image" />
<div class="item--details columns">
<h4 class="item--name">{{ inbox.label }}</h4>
<p class="item--sub">Facebook</p>
<h4 class="item--name">
{{ inbox.label }}
</h4>
<p class="item--sub">
Facebook
</p>
</div>
<!-- <span class="ion-chevron-right arrow"></span> -->
</div>
</template>
<script>
/* eslint no-console: 0 */
/* global bus */
// import WootSwitch from '../ui/Switch';
export default {
props: ['inbox'],
created() {
},
created() {},
};
</script>

View File

@@ -0,0 +1,67 @@
import { action } from '@storybook/addon-actions';
import LabelSelector from './LabelSelector';
export default {
title: 'Components/Label/Contact Label',
component: LabelSelector,
argTypes: {
contactId: {
control: {
type: 'text ,number',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LabelSelector },
template:
'<label-selector v-bind="$props" @add="onAdd" @remove="onRemove"></label-selector>',
});
export const ContactLabel = Template.bind({});
ContactLabel.args = {
onAdd: action('Added'),
onRemove: action('Removed'),
allLabels: [
{
id: '1',
title: 'sales',
description: '',
color: '#0a5dd1',
},
{
id: '2',
title: 'refund',
description: '',
color: '#8442f5',
},
{
id: '3',
title: 'testing',
description: '',
color: '#f542f5',
},
{
id: '4',
title: 'scheduled',
description: '',
color: '#42d1f5',
},
],
savedLabels: [
{
id: '2',
title: 'refund',
description: '',
color: '#8442f5',
},
{
id: '4',
title: 'scheduled',
description: '',
color: '#42d1f5',
},
],
};

View File

@@ -0,0 +1,116 @@
<template>
<div>
<h6 class="text-block-title">
<i class="title-icon ion-pricetags" />
{{ $t('CONTACT_PANEL.LABELS.CONTACT.TITLE') }}
</h6>
<div v-on-clickaway="closeDropdownLabel" class="label-wrap">
<add-label @add="toggleLabels" />
<woot-label
v-for="label in savedLabels"
:key="label.id"
:title="label.title"
:description="label.description"
:show-close="true"
:bg-color="label.color"
@click="removeItem"
/>
<div class="dropdown-wrap">
<div
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
class="dropdown-pane"
>
<label-dropdown
v-if="showSearchDropdownLabel"
:account-labels="allLabels"
:selected-labels="selectedLabels"
@add="addItem"
@remove="removeItem"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import AddLabel from 'shared/components/ui/dropdown/AddLabel';
import LabelDropdown from 'shared/components/ui/label/LabelDropdown';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
AddLabel,
LabelDropdown,
},
mixins: [clickaway],
props: {
allLabels: {
type: Array,
default: () => [],
},
savedLabels: {
type: Array,
default: () => [],
},
},
data() {
return {
showSearchDropdownLabel: false,
};
},
computed: {
selectedLabels() {
return this.savedLabels.map(label => label.title);
},
},
methods: {
addItem(label) {
this.$emit('add', label);
},
removeItem(label) {
this.$emit('remove', label);
},
toggleLabels() {
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
},
closeDropdownLabel() {
this.showSearchDropdownLabel = false;
},
},
};
</script>
<style lang="scss" scoped>
.title-icon {
margin-right: var(--space-smaller);
}
.label-wrap {
position: relative;
margin-left: var(--space-two);
line-height: var(--space-medium);
.dropdown-wrap {
display: flex;
position: absolute;
margin-right: var(--space-medium);
top: var(--space-medium);
width: 100%;
left: -1px;
.dropdown-pane {
width: 100%;
box-sizing: border-box;
}
}
}
</style>

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

@@ -14,6 +14,8 @@
>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
class-names="goto-first"
:is-disabled="hasFirstPage"
@click="onFirstPage"
@@ -23,16 +25,25 @@
</woot-button>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
:is-disabled="hasPrevPage"
@click="onPrevPage"
>
<i class="ion-chevron-left" />
</woot-button>
<woot-button @click.prevent>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
@click.prevent
>
{{ currentPage }}
</woot-button>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
:is-disabled="hasNextPage"
@click="onNextPage"
>
@@ -40,6 +51,8 @@
</woot-button>
<woot-button
size="small"
variant="clear"
color-scheme="secondary"
class-names="goto-last"
:is-disabled="hasLastPage"
@click="onLastPage"
@@ -147,52 +160,11 @@ export default {
.page-meta {
font-size: var(--font-size-mini);
}
.pagination-button-group {
margin: 0;
.button {
background: transparent;
border-color: var(--color-border);
color: var(--color-body);
margin-bottom: 0;
margin-left: -2px;
font-size: var(--font-size-small);
padding: var(--space-small) var(--space-normal);
border-radius: 0;
&:hover,
&:focus,
&:active {
background: var(--s-200);
color: white;
}
&:first-child {
border-top-left-radius: var(--space-smaller);
border-bottom-left-radius: var(--space-smaller);
}
&:last-child {
border-top-right-radius: var(--space-smaller);
border-bottom-right-radius: var(--space-smaller);
}
&.small {
font-size: var(--font-size-micro);
}
&.disabled {
background: var(--s-200);
border-color: var(--s-200);
color: var(--b-900);
}
&.goto-first,
&.goto-last {
i:last-child {
margin-left: var(--space-minus-smaller);
}
}
.goto-first,
.goto-last {
i:last-child {
margin-left: var(--space-minus-smaller);
}
}
</style>

View File

@@ -15,7 +15,8 @@ describe(`when there are NO errors loading the thumbnail`, () => {
},
});
expect(wrapper.find('#image').exists()).toBe(true);
expect(wrapper.contains(Avatar)).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(false);
});
});
@@ -31,8 +32,9 @@ describe(`when there ARE errors loading the thumbnail`, () => {
};
},
});
expect(wrapper.contains('#image')).toBe(false);
expect(wrapper.contains(Avatar)).toBe(true);
expect(wrapper.find('#image').exists()).toBe(false);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(true);
});
});

View File

@@ -5,30 +5,40 @@
:search-key="mentionSearchKey"
@click="insertMentionNode"
/>
<canned-response
v-if="showCannedMenu"
:search-key="cannedSearchTerm"
@click="insertCannedResponse"
/>
<div ref="editor"></div>
</div>
</template>
<script>
import { EditorView } from 'prosemirror-view';
import { defaultMarkdownSerializer } from 'prosemirror-markdown';
const TYPING_INDICATOR_IDLE_TIME = 4000;
import {
addMentionsToMarkdownSerializer,
addMentionsToMarkdownParser,
schemaWithMentions,
} from '@chatwoot/prosemirror-schema/src/mentions/schema';
import {
suggestionsPlugin,
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import TagAgents from '../conversation/TagAgents.vue';
import { EditorState } from 'prosemirror-state';
import { defaultMarkdownParser } from 'prosemirror-markdown';
import { wootWriterSetup } from '@chatwoot/prosemirror-schema';
import TagAgents from '../conversation/TagAgents';
import CannedResponse from '../conversation/CannedResponse';
const TYPING_INDICATOR_IDLE_TIME = 4000;
import '@chatwoot/prosemirror-schema/src/woot-editor.css';
const createState = (content, placeholder, plugins = []) => {
return EditorState.create({
doc: addMentionsToMarkdownParser(defaultMarkdownParser).parse(content),
@@ -42,7 +52,7 @@ const createState = (content, placeholder, plugins = []) => {
export default {
name: 'WootMessageEditor',
components: { TagAgents },
components: { TagAgents, CannedResponse },
props: {
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
@@ -52,7 +62,9 @@ export default {
return {
lastValue: null,
showUserMentions: false,
showCannedMenu: false,
mentionSearchKey: '',
cannedSearchTerm: '',
editorView: null,
range: null,
};
@@ -85,6 +97,35 @@ export default {
return event.keyCode === 13 && this.showUserMentions;
},
}),
suggestionsPlugin({
matcher: triggerCharacters('/'),
suggestionClass: '',
onEnter: args => {
if (this.isPrivate) {
return false;
}
this.showCannedMenu = true;
this.range = args.range;
this.editorView = args.view;
return false;
},
onChange: args => {
this.editorView = args.view;
this.range = args.range;
this.cannedSearchTerm = args.text.replace('/', '');
return false;
},
onExit: () => {
this.cannedSearchTerm = '';
this.showCannedMenu = false;
this.editorView = null;
return false;
},
onKeyDown: ({ event }) => {
return event.keyCode === 13 && this.showCannedMenu;
},
}),
];
},
},
@@ -92,9 +133,14 @@ export default {
showUserMentions(updatedValue) {
this.$emit('toggle-user-mention', this.isPrivate && updatedValue);
},
value(newValue) {
showCannedMenu(updatedValue) {
this.$emit('toggle-canned-menu', !this.isPrivate && updatedValue);
},
value(newValue = '') {
if (newValue !== this.lastValue) {
this.state = createState(newValue, this.placeholder, this.plugins);
const { tr } = this.state;
tr.insertText(newValue, 0, tr.doc.content.size);
this.state = this.view.state.apply(tr);
this.view.updateState(this.state);
}
},
@@ -140,6 +186,21 @@ export default {
this.state = this.view.state.apply(tr);
return this.emitOnChange();
},
insertCannedResponse(cannedItem) {
if (!this.view) {
return null;
}
const tr = this.view.state.tr.insertText(
cannedItem,
this.range.from,
this.range.to
);
this.state = this.view.state.apply(tr);
return this.emitOnChange();
},
emitOnChange() {
this.view.updateState(this.state);
this.lastValue = addMentionsToMarkdownSerializer(
@@ -205,10 +266,9 @@ export default {
.is-private {
.prosemirror-mention-node {
font-weight: var(--font-weight-medium);
background: var(--s-300);
border-radius: var(--border-radius-small);
padding: 1px 4px;
color: var(--white);
background: var(--s-50);
color: var(--s-900);
padding: 0 var(--space-smaller);
}
}
</style>

View File

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

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