diff --git a/.env.example b/.env.example index 624a44a7b..08e72816e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml index 668951de2..4bec94c82 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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: diff --git a/.storybook/preview.js b/.storybook/preview.js index 40a2ce3ea..c62c3ef00 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -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); diff --git a/Gemfile b/Gemfile index 4e430a7c8..ae0938676 100644 --- a/Gemfile +++ b/Gemfile @@ -59,6 +59,7 @@ gem 'barnes' ##--- gems for authentication & authorization ---## gem 'devise' +gem 'devise-secure_password', '~> 2.0' gem 'devise_token_auth' # authorization gem 'jwt' @@ -72,7 +73,7 @@ gem 'wisper', '2.0.0' ##--- gems for channels ---## # TODO: bump up gem to 2.0 -gem 'facebook-messenger', '1.5.0' +gem 'facebook-messenger' gem 'telegram-bot-ruby' gem 'twilio-ruby', '~> 5.32.0' # twitty will handle subscription of twitter account events @@ -132,8 +133,6 @@ group :test do end group :development, :test do - # locking until https://github.com/codeclimate/test-reporter/issues/418 is resolved - gem 'action-cable-testing' gem 'bundle-audit', require: false gem 'byebug', platform: :mri gem 'factory_bot_rails' diff --git a/Gemfile.lock b/Gemfile.lock index e3061c705..55d3dbd29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,8 +16,6 @@ GIT GEM remote: https://rubygems.org/ specs: - action-cable-testing (0.6.1) - actioncable (>= 5.0) actioncable (6.0.3.7) actionpack (= 6.0.3.7) nio4r (~> 2.0) @@ -125,7 +123,7 @@ GEM barnes (0.0.8) multi_json (~> 1) statsd-ruby (~> 1.1) - bcrypt (3.1.15) + bcrypt (3.1.16) bindex (0.8.1) bootsnap (1.4.8) msgpack (~> 1.0) @@ -160,12 +158,15 @@ GEM declarative-option (0.1.0) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) - devise (4.7.2) + devise (4.8.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) responders warden (~> 1.2.3) + devise-secure_password (2.0.1) + devise (>= 4.0.0, < 5.0.0) + railties (>= 5.0.0, < 7.0.0) devise_token_auth (1.1.4) bcrypt (~> 3.0) devise (> 3.5.2, < 5) @@ -188,7 +189,7 @@ GEM et-orbi (1.2.4) tzinfo execjs (2.7.0) - facebook-messenger (1.5.0) + facebook-messenger (2.0.1) httparty (~> 0.13, >= 0.13.7) rack (>= 1.4.5) factory_bot (6.1.0) @@ -261,7 +262,7 @@ GEM multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (~> 0.14) - groupdate (5.1.0) + groupdate (5.2.2) activesupport (>= 5) grpc (1.37.1) google-protobuf (~> 3.15) @@ -335,7 +336,7 @@ GEM method_source (1.0.0) mime-types (3.3.1) mime-types-data (~> 3.2015) - mime-types-data (3.2020.0512) + mime-types-data (3.2021.0225) mini_magick (4.10.1) mini_mime (1.1.0) mini_portile2 (2.5.1) @@ -350,7 +351,7 @@ GEM connection_pool (~> 2.2) netrc (0.11.0) nio4r (2.5.7) - nokogiri (1.11.3) + nokogiri (1.11.6) mini_portile2 (~> 2.5.0) racc (~> 1.4) oauth (0.5.6) @@ -368,7 +369,7 @@ GEM pry-rails (0.3.9) pry (>= 0.10.4) public_suffix (4.0.6) - puma (4.3.6) + puma (4.3.8) nio4r (~> 2.0) pundit (2.1.0) activesupport (>= 3.0.0) @@ -584,8 +585,8 @@ GEM coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) equalizer (~> 0.0, >= 0.0.9) - warden (1.2.8) - rack (>= 2.0.6) + warden (1.2.9) + rack (>= 2.0.9) web-console (4.0.4) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -613,7 +614,6 @@ PLATFORMS ruby DEPENDENCIES - action-cable-testing activerecord-import acts-as-taggable-on administrate @@ -632,9 +632,10 @@ DEPENDENCIES cypress-on-rails (~> 1.0) database_cleaner devise + devise-secure_password (~> 2.0) devise_token_auth dotenv-rails - facebook-messenger (= 1.5.0) + facebook-messenger factory_bot_rails faker fcm diff --git a/app/bot/facebook_bot.rb b/app/bot/facebook_bot.rb index f3f87220d..c13cf12ed 100644 --- a/app/bot/facebook_bot.rb +++ b/app/bot/facebook_bot.rb @@ -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 diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index f7f94fada..0464c8013 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -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! diff --git a/app/builders/contact_builder.rb b/app/builders/contact_builder.rb index f7fcdfb12..92c706a8a 100644 --- a/app/builders/contact_builder.rb +++ b/app/builders/contact_builder.rb @@ -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], diff --git a/app/builders/messages/facebook/message_builder.rb b/app/builders/messages/facebook/message_builder.rb index 447fe3cec..f0fc21f7d 100644 --- a/app/builders/messages/facebook/message_builder.rb +++ b/app/builders/messages/facebook/message_builder.rb @@ -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) diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 6fc3bab5d..ba78d76d7 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -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 diff --git a/app/controllers/android_app_controller.rb b/app/controllers/android_app_controller.rb new file mode 100644 index 000000000..c403d7756 --- /dev/null +++ b/app/controllers/android_app_controller.rb @@ -0,0 +1,5 @@ +class AndroidAppController < ApplicationController + def assetlinks + render layout: false + end +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb index 0cfd7205b..937cd5502 100644 --- a/app/controllers/api/base_controller.rb +++ b/app/controllers/api/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb new file mode 100644 index 000000000..7348ef255 --- /dev/null +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/agents_controller.rb b/app/controllers/api/v1/accounts/agents_controller.rb index 83f1ad186..334844de9 100644 --- a/app/controllers/api/v1/accounts/agents_controller.rb +++ b/app/controllers/api/v1/accounts/agents_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index c4555017d..5c58d3f51 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb index 16686dbfe..fa5b271bf 100644 --- a/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/contact_inboxes_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb index 576fbaa2f..4ccc14729 100644 --- a/app/controllers/api/v1/accounts/contacts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/contacts/conversations_controller.rb @@ -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 [] diff --git a/app/controllers/api/v1/accounts/contacts_controller.rb b/app/controllers/api/v1/accounts/contacts_controller.rb index ac27314fc..46f6b83c7 100644 --- a/app/controllers/api/v1/accounts/contacts_controller.rb +++ b/app/controllers/api/v1/accounts/contacts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index 2dae59abd..f521719ae 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index dccb56a95..9f4f0b45d 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/facebook_indicators_controller.rb b/app/controllers/api/v1/accounts/facebook_indicators_controller.rb deleted file mode 100644 index a0e42c040..000000000 --- a/app/controllers/api/v1/accounts/facebook_indicators_controller.rb +++ /dev/null @@ -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 diff --git a/app/controllers/api/v1/accounts/inbox_members_controller.rb b/app/controllers/api/v1/accounts/inbox_members_controller.rb index f715c7040..27e31c855 100644 --- a/app/controllers/api/v1/accounts/inbox_members_controller.rb +++ b/app/controllers/api/v1/accounts/inbox_members_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 014e213d4..a51681617 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -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, diff --git a/app/controllers/api/v1/accounts/integrations/apps_controller.rb b/app/controllers/api/v1/accounts/integrations/apps_controller.rb index f88cc7c5f..7ec5cc5ec 100644 --- a/app/controllers/api/v1/accounts/integrations/apps_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/apps_controller.rb @@ -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] diff --git a/app/controllers/api/v1/accounts/integrations/slack_controller.rb b/app/controllers/api/v1/accounts/integrations/slack_controller.rb index 5ded9f512..537ddd688 100644 --- a/app/controllers/api/v1/accounts/integrations/slack_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/slack_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 8fbb56c19..f67972486 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/agent_bots_controller.rb b/app/controllers/api/v1/agent_bots_controller.rb deleted file mode 100644 index a82f71bb9..000000000 --- a/app/controllers/api/v1/agent_bots_controller.rb +++ /dev/null @@ -1,7 +0,0 @@ -class Api::V1::AgentBotsController < Api::BaseController - skip_before_action :authenticate_user! - - def index - render json: AgentBot.all - end -end diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index c59edfce0..5805f49ed 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb index cc68cb888..8d28345d4 100644 --- a/app/controllers/api/v1/widget/conversations_controller.rb +++ b/app/controllers/api/v1/widget/conversations_controller.rb @@ -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 diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb index 6def00a3a..991b2f428 100644 --- a/app/controllers/api/v1/widget/messages_controller.rb +++ b/app/controllers/api/v1/widget/messages_controller.rb @@ -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 diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb index 3203f34e9..8fc980255 100644 --- a/app/controllers/api/v2/accounts/reports_controller.rb +++ b/app/controllers/api/v2/accounts/reports_controller.rb @@ -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, diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 91bd461aa..2f4dc4337 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -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') diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 14030b565..53c0abb02 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -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] ) diff --git a/app/controllers/devise_overrides/confirmations_controller.rb b/app/controllers/devise_overrides/confirmations_controller.rb index 0cb71f0d9..02d412cb1 100644 --- a/app/controllers/devise_overrides/confirmations_controller.rb +++ b/app/controllers/devise_overrides/confirmations_controller.rb @@ -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 diff --git a/app/controllers/devise_overrides/passwords_controller.rb b/app/controllers/devise_overrides/passwords_controller.rb index ed9d012eb..501b9f90c 100644 --- a/app/controllers/devise_overrides/passwords_controller.rb +++ b/app/controllers/devise_overrides/passwords_controller.rb @@ -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 diff --git a/app/controllers/platform/api/v1/agent_bots_controller.rb b/app/controllers/platform/api/v1/agent_bots_controller.rb new file mode 100644 index 000000000..0c79e118d --- /dev/null +++ b/app/controllers/platform/api/v1/agent_bots_controller.rb @@ -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 diff --git a/app/controllers/public/api/v1/inboxes/contacts_controller.rb b/app/controllers/public/api/v1/inboxes/contacts_controller.rb new file mode 100644 index 000000000..6932386fd --- /dev/null +++ b/app/controllers/public/api/v1/inboxes/contacts_controller.rb @@ -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 diff --git a/app/controllers/public/api/v1/inboxes/conversations_controller.rb b/app/controllers/public/api/v1/inboxes/conversations_controller.rb new file mode 100644 index 000000000..a399bc749 --- /dev/null +++ b/app/controllers/public/api/v1/inboxes/conversations_controller.rb @@ -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 diff --git a/app/controllers/public/api/v1/inboxes/messages_controller.rb b/app/controllers/public/api/v1/inboxes/messages_controller.rb new file mode 100644 index 000000000..65a21183a --- /dev/null +++ b/app/controllers/public/api/v1/inboxes/messages_controller.rb @@ -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 diff --git a/app/controllers/public/api/v1/inboxes_controller.rb b/app/controllers/public/api/v1/inboxes_controller.rb new file mode 100644 index 000000000..a57e72e40 --- /dev/null +++ b/app/controllers/public/api/v1/inboxes_controller.rb @@ -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 diff --git a/app/controllers/public_controller.rb b/app/controllers/public_controller.rb new file mode 100644 index 000000000..615a5d610 --- /dev/null +++ b/app/controllers/public_controller.rb @@ -0,0 +1,3 @@ +class PublicController < ActionController::Base + skip_before_action :verify_authenticity_token +end diff --git a/app/dashboards/agent_bot_dashboard.rb b/app/dashboards/agent_bot_dashboard.rb index ca6ca7ad1..87605135d 100644 --- a/app/dashboards/agent_bot_dashboard.rb +++ b/app/dashboards/agent_bot_dashboard.rb @@ -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 diff --git a/app/dashboards/super_admin_dashboard.rb b/app/dashboards/super_admin_dashboard.rb index ab467a255..1d9afd1ba 100644 --- a/app/dashboards/super_admin_dashboard.rb +++ b/app/dashboards/super_admin_dashboard.rb @@ -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 diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index 4336aff11..8791da5b9 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -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 diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index 4a1ca37f1..558eb4933 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -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); + }); }); }, diff --git a/app/javascript/dashboard/api/contactNotes.js b/app/javascript/dashboard/api/contactNotes.js new file mode 100644 index 000000000..9508ea9dc --- /dev/null +++ b/app/javascript/dashboard/api/contactNotes.js @@ -0,0 +1,9 @@ +import ApiClient from './ApiClient'; + +class ContactNotes extends ApiClient { + constructor() { + super('contact_notes', { accountScoped: true }); + } +} + +export default new ContactNotes(); diff --git a/app/javascript/dashboard/api/contacts.js b/app/javascript/dashboard/api/contacts.js index 1a66db0e1..cc8448873 100644 --- a/app/javascript/dashboard/api/contacts.js +++ b/app/javascript/dashboard/api/contacts.js @@ -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}` diff --git a/app/javascript/dashboard/api/integrations.js b/app/javascript/dashboard/api/integrations.js index 587c94d43..72e433c25 100644 --- a/app/javascript/dashboard/api/integrations.js +++ b/app/javascript/dashboard/api/integrations.js @@ -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(); diff --git a/app/javascript/dashboard/api/specs/account.spec.js b/app/javascript/dashboard/api/specs/account.spec.js new file mode 100644 index 000000000..3d37c4206 --- /dev/null +++ b/app/javascript/dashboard/api/specs/account.spec.js @@ -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', + }); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/apiSpecHelper.js b/app/javascript/dashboard/api/specs/apiSpecHelper.js new file mode 100644 index 000000000..aab90b045 --- /dev/null +++ b/app/javascript/dashboard/api/specs/apiSpecHelper.js @@ -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); + }); +} diff --git a/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js b/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js index 03f1cb649..67697f827 100644 --- a/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js +++ b/app/javascript/dashboard/api/specs/channel/fbChannel.spec.js @@ -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, + } + ); + }); + }); }); diff --git a/app/javascript/dashboard/api/specs/channel/twilioChannel.spec.js b/app/javascript/dashboard/api/specs/channel/twilioChannel.spec.js new file mode 100644 index 000000000..5025de44f --- /dev/null +++ b/app/javascript/dashboard/api/specs/channel/twilioChannel.spec.js @@ -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'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js b/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js index 0480aeb82..0768ee53e 100644 --- a/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js +++ b/app/javascript/dashboard/api/specs/channel/twitterClient.spec.js @@ -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'); }); }); diff --git a/app/javascript/dashboard/api/specs/channel/webChannel.spec.js b/app/javascript/dashboard/api/specs/channel/webChannel.spec.js new file mode 100644 index 000000000..c14e79ea7 --- /dev/null +++ b/app/javascript/dashboard/api/specs/channel/webChannel.spec.js @@ -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'); + }); +}); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index 2cc89ccc8..a7080a634 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -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' + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/conversations.spec.js b/app/javascript/dashboard/api/specs/conversations.spec.js index 2ac815459..4b1a0a030 100644 --- a/app/javascript/dashboard/api/specs/conversations.spec.js +++ b/app/javascript/dashboard/api/specs/conversations.spec.js @@ -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, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/endPoints.spec.js b/app/javascript/dashboard/api/specs/endPoints.spec.js new file mode 100644 index 000000000..8c46735a9 --- /dev/null +++ b/app/javascript/dashboard/api/specs/endPoints.spec.js @@ -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 }, + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js index 6d4cdeec4..b4951804a 100644 --- a/app/javascript/dashboard/api/specs/inbox/conversation.spec.js +++ b/app/javascript/dashboard/api/specs/inbox/conversation.spec.js @@ -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', + } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/inbox/message.spec.js b/app/javascript/dashboard/api/specs/inbox/message.spec.js new file mode 100644 index 000000000..dd4814f23 --- /dev/null +++ b/app/javascript/dashboard/api/specs/inbox/message.spec.js @@ -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, + }, + } + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index c4927e2bb..6c5cf38ea 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -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' + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/integrations.spec.js b/app/javascript/dashboard/api/specs/integrations.spec.js new file mode 100644 index 000000000..05391ceb6 --- /dev/null +++ b/app/javascript/dashboard/api/specs/integrations.spec.js @@ -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' + ); + }); + }); +}); diff --git a/app/javascript/dashboard/api/specs/notifications.spec.js b/app/javascript/dashboard/api/specs/notifications.spec.js index 14f472ce9..d20c0d526 100644 --- a/app/javascript/dashboard/api/specs/notifications.spec.js +++ b/app/javascript/dashboard/api/specs/notifications.spec.js @@ -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' + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index fabe22c94..0ca5f4be7 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -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, + }, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/teams.spec.js b/app/javascript/dashboard/api/specs/teams.spec.js index 6b01ab359..4f29af554 100644 --- a/app/javascript/dashboard/api/specs/teams.spec.js +++ b/app/javascript/dashboard/api/specs/teams.spec.js @@ -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, + } + ); + }); }); }); diff --git a/app/javascript/dashboard/api/specs/webhook.spec.js b/app/javascript/dashboard/api/specs/webhook.spec.js new file mode 100644 index 000000000..5a1db9db6 --- /dev/null +++ b/app/javascript/dashboard/api/specs/webhook.spec.js @@ -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'); + }); +}); diff --git a/app/javascript/dashboard/assets/scss/_formulate.scss b/app/javascript/dashboard/assets/scss/_formulate.scss new file mode 100644 index 000000000..57c43a3d6 --- /dev/null +++ b/app/javascript/dashboard/assets/scss/_formulate.scss @@ -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%; + } +} diff --git a/app/javascript/dashboard/assets/scss/_foundation-custom.scss b/app/javascript/dashboard/assets/scss/_foundation-custom.scss index 9c398e078..6fc3961fc 100644 --- a/app/javascript/dashboard/assets/scss/_foundation-custom.scss +++ b/app/javascript/dashboard/assets/scss/_foundation-custom.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/_foundation-settings.scss b/app/javascript/dashboard/assets/scss/_foundation-settings.scss index dbdb630c0..c74b9f05b 100644 --- a/app/javascript/dashboard/assets/scss/_foundation-settings.scss +++ b/app/javascript/dashboard/assets/scss/_foundation-settings.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/_typography.scss b/app/javascript/dashboard/assets/scss/_typography.scss index 8c6d2e0f2..abab48564 100644 --- a/app/javascript/dashboard/assets/scss/_typography.scss +++ b/app/javascript/dashboard/assets/scss/_typography.scss @@ -19,7 +19,7 @@ } .text-muted { - color: $color-gray; + color: var(--s-300); } a { diff --git a/app/javascript/dashboard/assets/scss/_woot.scss b/app/javascript/dashboard/assets/scss/_woot.scss index e53ceef73..d156f77b9 100644 --- a/app/javascript/dashboard/assets/scss/_woot.scss +++ b/app/javascript/dashboard/assets/scss/_woot.scss @@ -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'; diff --git a/app/javascript/dashboard/assets/scss/app.scss b/app/javascript/dashboard/assets/scss/app.scss index dbf3a6e78..13c3dd557 100644 --- a/app/javascript/dashboard/assets/scss/app.scss +++ b/app/javascript/dashboard/assets/scss/app.scss @@ -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'; diff --git a/app/javascript/dashboard/assets/scss/storybook.scss b/app/javascript/dashboard/assets/scss/storybook.scss index fbe88bc3f..f6a9d5eb3 100644 --- a/app/javascript/dashboard/assets/scss/storybook.scss +++ b/app/javascript/dashboard/assets/scss/storybook.scss @@ -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); diff --git a/app/javascript/dashboard/assets/scss/views/settings/channel.scss b/app/javascript/dashboard/assets/scss/views/settings/channel.scss index 01828eb4d..80400bb71 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/channel.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/channel.scss @@ -31,8 +31,9 @@ } img { - width: 50%; @include margin($space-normal auto); + flex: 1; + width: 50%; } .channel__title{ diff --git a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss index 27ff6fb1f..cc2abcf9b 100644 --- a/app/javascript/dashboard/assets/scss/views/settings/inbox.scss +++ b/app/javascript/dashboard/assets/scss/views/settings/inbox.scss @@ -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 { diff --git a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss index a599721c2..edbef16bb 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_buttons.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_buttons.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss index 1c081c089..f286c7bc0 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conv-header.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss index bc3e20254..d79cfe80d 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_conversation-view.scss @@ -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; diff --git a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss index c09812527..8a53545fe 100644 --- a/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss +++ b/app/javascript/dashboard/assets/scss/widgets/_reply-box.scss @@ -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); - } - } } diff --git a/app/javascript/dashboard/components/ChatList.vue b/app/javascript/dashboard/components/ChatList.vue index 3b7fe0b8c..0aba2739f 100644 --- a/app/javascript/dashboard/components/ChatList.vue +++ b/app/javascript/dashboard/components/ChatList.vue @@ -32,13 +32,14 @@ -
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }} -
+

-@import '~dashboard/assets/scss/app.scss'; +@import '~dashboard/assets/scss/woot'; .spinner { margin-top: var(--space-normal); margin-bottom: var(--space-normal); diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 0343a20ff..5c429d237 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -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" - > - + />

- + - + - + {{ $t('SIDEBAR_ITEMS.PROFILE_SETTINGS') }} - + {{ $t('SIDEBAR_ITEMS.LOGOUT') }} - +
diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js b/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js new file mode 100644 index 000000000..09bd6f120 --- /dev/null +++ b/app/javascript/dashboard/components/layout/sidebarComponents/specs/AccountSelector.spec.js @@ -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); + }); +}); diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/specs/AgentDetails.spec.js b/app/javascript/dashboard/components/layout/sidebarComponents/specs/AgentDetails.spec.js new file mode 100644 index 000000000..142bbedfa --- /dev/null +++ b/app/javascript/dashboard/components/layout/sidebarComponents/specs/AgentDetails.spec.js @@ -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); + }); +}); diff --git a/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js b/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js new file mode 100644 index 000000000..947e5976b --- /dev/null +++ b/app/javascript/dashboard/components/layout/sidebarComponents/specs/NotificationBell.spec.js @@ -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+'); + }); +}); diff --git a/app/javascript/dashboard/components/ui/Label.vue b/app/javascript/dashboard/components/ui/Label.vue index a6c09e562..5415444d2 100644 --- a/app/javascript/dashboard/components/ui/Label.vue +++ b/app/javascript/dashboard/components/ui/Label.vue @@ -7,7 +7,7 @@ - diff --git a/app/javascript/dashboard/components/widgets/Button.vue b/app/javascript/dashboard/components/widgets/Button.vue deleted file mode 100644 index b3d582cfe..000000000 --- a/app/javascript/dashboard/components/widgets/Button.vue +++ /dev/null @@ -1,49 +0,0 @@ - - - - - diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 32989cc76..37b82436e 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -17,9 +17,13 @@ src="~dashboard/assets/images/channels/telegram.png" /> + -
- No Page Image +
+ No Page Image
-

{{ inbox.label }}

-

Facebook

+

+ {{ inbox.label }} +

+

+ Facebook +

diff --git a/app/javascript/dashboard/components/widgets/LabelSelector.stories.js b/app/javascript/dashboard/components/widgets/LabelSelector.stories.js new file mode 100644 index 000000000..77ca54b25 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/LabelSelector.stories.js @@ -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: + '', +}); + +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', + }, + ], +}; diff --git a/app/javascript/dashboard/components/widgets/LabelSelector.vue b/app/javascript/dashboard/components/widgets/LabelSelector.vue new file mode 100644 index 000000000..8a6270bb8 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/LabelSelector.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/app/javascript/dashboard/components/widgets/SettingIntroBanner.stories.js b/app/javascript/dashboard/components/widgets/SettingIntroBanner.stories.js new file mode 100644 index 000000000..47286ae21 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/SettingIntroBanner.stories.js @@ -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: '', +}); + +export const Banner = Template.bind({}); +Banner.args = {}; diff --git a/app/javascript/dashboard/components/widgets/SettingIntroBanner.vue b/app/javascript/dashboard/components/widgets/SettingIntroBanner.vue new file mode 100644 index 000000000..557880056 --- /dev/null +++ b/app/javascript/dashboard/components/widgets/SettingIntroBanner.vue @@ -0,0 +1,33 @@ + + + + diff --git a/app/javascript/dashboard/components/widgets/TableFooter.vue b/app/javascript/dashboard/components/widgets/TableFooter.vue index c951aad60..b9faa2f6e 100644 --- a/app/javascript/dashboard/components/widgets/TableFooter.vue +++ b/app/javascript/dashboard/components/widgets/TableFooter.vue @@ -14,6 +14,8 @@ > - + {{ currentPage }} @@ -40,6 +51,8 @@ diff --git a/app/javascript/dashboard/components/widgets/Thumbnail.spec.js b/app/javascript/dashboard/components/widgets/Thumbnail.spec.js index 5de534dbe..c10cd3d16 100644 --- a/app/javascript/dashboard/components/widgets/Thumbnail.spec.js +++ b/app/javascript/dashboard/components/widgets/Thumbnail.spec.js @@ -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); }); }); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index bbb1d4e82..ab4f5b5f1 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -5,30 +5,40 @@ :search-key="mentionSearchKey" @click="insertMentionNode" /> +
diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue index 0174076ba..440f6e1e5 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationBox.vue @@ -75,7 +75,7 @@ export default { }; diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index 0b2057a61..dd35fe927 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -12,8 +12,10 @@

{{ currentContact.name }}

- +
@@ -250,6 +258,13 @@ export default { color: var(--color-body); text-decoration: underline; } + + &.is-from-bot { + background: var(--v-400); + .message-text--metadata .time { + color: var(--v-50); + } + } } &.is-pending { diff --git a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue index 0b89fb8c4..d825e4634 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MessagesView.vue @@ -339,5 +339,6 @@ export default { .view-box.fill-height { height: auto; flex-grow: 1; + min-width: 0; } diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 81c94640f..5d97076bb 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -42,6 +42,7 @@ @focus="onFocus" @blur="onBlur" @toggle-user-mention="toggleUserMention" + @toggle-canned-menu="toggleCannedMenu" />
@@ -249,7 +250,8 @@ export default { } }, message(updatedMessage) { - this.hasSlashCommand = updatedMessage[0] === '/'; + this.hasSlashCommand = + updatedMessage[0] === '/' && !this.showRichContentEditor; const hasNextWord = updatedMessage.includes(' '); const isShortCodeActive = this.hasSlashCommand && !hasNextWord; if (isShortCodeActive) { @@ -271,6 +273,9 @@ export default { toggleUserMention(currentMentionState) { this.hasUserMention = currentMentionState; }, + toggleCannedMenu(value) { + this.showCannedMenu = value; + }, handleKeyEvents(e) { if (isEscape(e)) { this.hideEmojiPicker(); @@ -279,7 +284,8 @@ export default { const hasSendOnEnterEnabled = (this.showRichContentEditor && this.enterToSendEnabled && - !this.hasUserMention) || + !this.hasUserMention && + !this.showCannedMenu) || !this.showRichContentEditor; const shouldSendMessage = hasSendOnEnterEnabled && !hasPressedShift(e) && this.isFocused; diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue index 9c96d44a5..8c6954672 100644 --- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue @@ -95,7 +95,7 @@ export default { diff --git a/app/javascript/dashboard/modules/contact/stories/AddCustomAttribute.stories.js b/app/javascript/dashboard/modules/contact/stories/AddCustomAttribute.stories.js new file mode 100644 index 000000000..603cb5167 --- /dev/null +++ b/app/javascript/dashboard/modules/contact/stories/AddCustomAttribute.stories.js @@ -0,0 +1,32 @@ +import AddCustomAttribute from '../components/AddCustomAttribute'; +import { action } from '@storybook/addon-actions'; + +export default { + title: 'Components/Contact/AddCustomAttribute', + component: AddCustomAttribute, + argTypes: { + show: { + defaultValue: true, + control: { + type: 'boolean', + }, + }, + isCreating: { + defaultValue: false, + control: { + type: 'boolean', + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { AddCustomAttribute }, + template: '', +}); + +export const DefaultAttribute = Template.bind({}); +DefaultAttribute.args = { + onCreate: action('edit'), +}; diff --git a/app/javascript/dashboard/modules/contact/stories/ContactFields.stories.js b/app/javascript/dashboard/modules/contact/stories/ContactFields.stories.js index 01a99f254..3b0b02be5 100644 --- a/app/javascript/dashboard/modules/contact/stories/ContactFields.stories.js +++ b/app/javascript/dashboard/modules/contact/stories/ContactFields.stories.js @@ -10,7 +10,7 @@ const Template = (args, { argTypes }) => ({ props: Object.keys(argTypes), components: { ContactFields }, template: - '', + '', }); export const DefaultContactFields = Template.bind({}); @@ -39,4 +39,5 @@ DefaultContactFields.args = { }, }, onUpdate: action('update'), + onCreate: action('create'), }; diff --git a/app/javascript/dashboard/modules/notes/NotesOnContactPage.vue b/app/javascript/dashboard/modules/notes/NotesOnContactPage.vue new file mode 100644 index 000000000..256f9435e --- /dev/null +++ b/app/javascript/dashboard/modules/notes/NotesOnContactPage.vue @@ -0,0 +1,43 @@ + + + diff --git a/app/javascript/dashboard/modules/notes/components/AddNote.vue b/app/javascript/dashboard/modules/notes/components/AddNote.vue new file mode 100644 index 000000000..2fe918924 --- /dev/null +++ b/app/javascript/dashboard/modules/notes/components/AddNote.vue @@ -0,0 +1,63 @@ + - - - - diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/AddReminder.stories.js b/app/javascript/dashboard/routes/dashboard/contacts/components/AddReminder.stories.js new file mode 100644 index 000000000..a9bdbb5e8 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/AddReminder.stories.js @@ -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: + '', +}); + +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', + }, + ], +}; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/AddReminder.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/AddReminder.vue new file mode 100644 index 000000000..0408d2c70 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/AddReminder.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue index 32082532e..3078e7fda 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactInfoPanel.vue @@ -8,6 +8,7 @@ v-if="hasContactAttributes" :custom-attributes="contact.custom_attributes" /> + diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue index a631c2d15..67faaac22 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsTable.vue @@ -114,13 +114,12 @@ export default { renderBodyCell: ({ row }) => ( this.onClickContact(row.id)} >
@@ -128,7 +127,7 @@ export default {
{row.name}
- + {this.$t('CONTACTS_PAGE.LIST.VIEW_DETAILS')}
@@ -292,7 +291,7 @@ export default { } .ve-table-body-td { - padding: var(--space-slab) var(--space-two) !important; + padding: var(--space-small) var(--space-two) !important; } .ve-table-header-th { diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue index f96e75077..826f0f827 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/Header.vue @@ -80,7 +80,7 @@ export default { display: flex; justify-content: space-between; width: 100%; - padding: var(--space-small) var(--space-small) var(--space-small) + padding: var(--space-small) var(--space-normal) var(--space-small) var(--space-normal); } @@ -96,7 +96,6 @@ export default { .search-wrap { width: 400px; - height: 3.8rem; display: flex; align-items: center; position: relative; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.stories.js b/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.stories.js new file mode 100644 index 000000000..853542357 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.stories.js @@ -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: + '', +}); + +export const Item = Template.bind({}); +Item.args = { + onClick: action('Marked'), + onEdit: action('Edit'), + onDelete: action('Delete'), +}; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue new file mode 100644 index 000000000..49755d1d2 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ReminderItem.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/SectionHeader.stories.js b/app/javascript/dashboard/routes/dashboard/contacts/components/SectionHeader.stories.js new file mode 100644 index 000000000..0ceaa64b4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/SectionHeader.stories.js @@ -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: + '', +}); + +export const Section = Template.bind({}); +Section.args = { + onClickNotes: action('notes'), + onClickEvents: action('events'), + onClickConversation: action('conversation'), +}; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/SectionHeader.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/SectionHeader.vue new file mode 100644 index 000000000..be7778440 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/SectionHeader.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/TimelineCard.stories.js b/app/javascript/dashboard/routes/dashboard/contacts/components/TimelineCard.stories.js new file mode 100644 index 000000000..2aedbcd15 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/TimelineCard.stories.js @@ -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: '', +}); + +export const Timeline = Template.bind({}); +Timeline.args = { + onClick: action('more'), +}; diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/TimelineCard.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/TimelineCard.vue new file mode 100644 index 000000000..a7f74fe58 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/contacts/components/TimelineCard.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue index 8b7707b3c..a94068801 100644 --- a/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue +++ b/app/javascript/dashboard/routes/dashboard/contacts/pages/ContactManageView.vue @@ -73,6 +73,9 @@ export default { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue index 960a503ca..54cef7362 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactConversations.vue @@ -77,13 +77,6 @@ export default { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/SocialIcons.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/SocialIcons.vue index 99c9ad83b..ef309499c 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/contact/SocialIcons.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/SocialIcons.vue @@ -42,15 +42,12 @@ export default { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/labels/AddLabelToConversation.vue b/app/javascript/dashboard/routes/dashboard/conversation/labels/AddLabelToConversation.vue deleted file mode 100644 index bdae422a9..000000000 --- a/app/javascript/dashboard/routes/dashboard/conversation/labels/AddLabelToConversation.vue +++ /dev/null @@ -1,136 +0,0 @@ - - - - diff --git a/app/javascript/dashboard/routes/dashboard/conversation/labels/LabelBox.vue b/app/javascript/dashboard/routes/dashboard/conversation/labels/LabelBox.vue index a3686fc11..162f853ed 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/labels/LabelBox.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/labels/LabelBox.vue @@ -1,11 +1,11 @@ @@ -91,4 +125,8 @@ export default { justify-content: flex-end; padding-bottom: var(--space-one); } + +.content-box .page-top-bar::v-deep { + padding: var(--space-large) var(--space-large) var(--space-zero); +} diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/CampaignsTable.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/CampaignsTable.vue index a37aa65ce..3e1855cb4 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/CampaignsTable.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/CampaignsTable.vue @@ -44,10 +44,6 @@ export default { type: Boolean, default: false, }, - onEditClick: { - type: Function, - default: () => {}, - }, }, data() { @@ -133,17 +129,25 @@ export default { key: 'buttons', title: '', align: 'left', - renderBodyCell: (row) => ( + renderBodyCell: row => (
this.onEditClick(row)} + classNames="grey-btn" + onClick={() => this.$emit('on-edit-click', row)} > {this.$t('CAMPAIGN.LIST.BUTTONS.EDIT')} + this.$emit('on-delete-click', row)} + > + {this.$t('CAMPAIGN.LIST.BUTTONS.DELETE')} +
), }, @@ -155,7 +159,7 @@ export default { if (this.isLoading) { return []; } - return this.campaigns.map((item) => { + return this.campaigns.map(item => { return { ...item, url: item.trigger_rules.url, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/EditCampaign.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/EditCampaign.vue index 61c873ad4..7696bc836 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/EditCampaign.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/EditCampaign.vue @@ -1,132 +1,101 @@