Merge branch 'develop' into feat/add_lograge

This commit is contained in:
Sojan Jose 2022-11-02 18:00:00 -07:00 committed by GitHub
commit 8de0293ab5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
595 changed files with 7617 additions and 2018 deletions

View file

@ -54,3 +54,5 @@ exclude_patterns:
- 'app/javascript/widget/i18n/index.js'
- 'app/javascript/survey/i18n/index.js'
- 'app/javascript/shared/constants/locales.js'
- 'app/javascript/dashboard/helper/specs/macrosFixtures.js'
- 'app/javascript/dashboard/routes/dashboard/settings/macros/constants.js'

View file

@ -7,8 +7,8 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = spaces
indent_style = space
tab_width = 2
[{*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
[*.{rb,erb,js,coffee,json,yml,css,scss,sh,markdown,md,html}]
indent_size = 2

View file

@ -34,6 +34,11 @@ REDIS_SENTINELS=
# You can find list of master using "SENTINEL masters" command
REDIS_SENTINEL_MASTER_NAME=
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
# Use the following environment variable to customize passwords for sentinels.
# Use empty string if sentinels are configured with out passwords
# REDIS_SENTINEL_PASSWORD=
# Redis premium breakage in heroku fix
# enable the following configuration
# ref: https://github.com/chatwoot/chatwoot/issues/2420

View file

@ -6,6 +6,7 @@ labels: 'Bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
@ -16,11 +17,11 @@ Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
4. See the error
**Expected behavior**
A clear and concise description of what you expected to happen.
Share a clear and concise description of what you expected to happen.
**Screenshots**
@ -28,27 +29,50 @@ If applicable, add screenshots to help explain your problem.
**Browser logs**
Share the browser logs to debug the issue further
Share the browser logs to debug the issue further.
**Server logs**
Share the server logs to debug the issue further
Share the server logs to debug the issue further.
**Environment**
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self hosted installation of Chatwoot. If you are using a self hosted installation of Chatwoot describe the type of deployment (Docker/Linux VM installation/Heroku)
Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- [ ] app.chatwoot.com (Chatwoot Cloud)
- [ ] Self-hosted
- - [ ] Linux VM
- - [ ] Docker
- - [ ] Kubernetes
- - [ ] Heroku
- - [ ] Other (Please specify)
**Desktop (please complete the following information)** (If applicable)
- OS: [e.g. Linux, Windows, MacOS]
- Browser [e.g. chrome, firefox, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
**Smartphone (please complete the following information)** (If applicable)
- Device: [e.g. iPhone6, Pixel7]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Browser [e.g. stock browser, firefox, safari]
- Version [e.g. 22]
**Docker** (If applicable)
Please share the output of the following.
- `docker version`
- `docker info`
- `docker-compose version`
**Cloud Provider** (If applicable)
- [ ] AWS
- [ ] GCP
- [ ] Azure
- [ ] DigitalOcean
- [ ] Others
**Additional context**
Add any other context about the problem here.

View file

@ -2,8 +2,7 @@
## Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
Fixes # (issue)
## Type of change
@ -12,18 +11,18 @@ Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings

View file

@ -58,6 +58,6 @@ jobs:
with:
context: .
file: docker/Dockerfile
platforms: linux/amd64,linux/arm64
platforms: linux/amd64
push: true
tags: ${{ env.DOCKER_TAG }}

5
.gitignore vendored
View file

@ -39,9 +39,6 @@ public/packs*
*.un~
.jest-cache
#VS Code files
.vscode
# ignore jetbrains IDE files
.idea
@ -62,4 +59,4 @@ package-lock.json
test/cypress/videos/*
/config/master.key
/config/*.enc
/config/*.enc

View file

@ -16,6 +16,7 @@ Metrics/ClassLength:
- 'app/models/message.rb'
- 'app/builders/messages/facebook/message_builder.rb'
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
- 'app/controllers/api/v1/accounts/conversations_controller.rb'
- 'app/listeners/action_cable_listener.rb'
- 'app/models/conversation.rb'
RSpec/ExampleLength:

32
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,32 @@
{
"recommendations": [
// Spell check
"streetsidesoftware.code-spell-checker",
// Better Comments
"aaron-bond.better-comments",
// Rails Test Runner
"davidpallinder.rails-test-runner",
// Eslint
"dbaeumer.vscode-eslint",
// Auto Close Tag
"formulahendry.auto-close-tag",
// Auto Rename Tag
"formulahendry.auto-rename-tag",
// Hight light colors
"naumovs.color-highlight",
// GitLens
"eamodio.gitlens",
// Ruby
"rebornix.ruby",
// Vue
"octref.vetur",
// Prettier
"esbenp.prettier-vscode",
// Dot Env
"mikestead.dotenv",
// HTML CSS Support
"ecmel.vscode-html-css",
// Tailwind CSS Intellisense
"bradlc.vscode-tailwindcss",
]
}

View file

@ -432,14 +432,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.9.0)
nio4r (2.5.8)
nokogiri (1.13.7)
nokogiri (1.13.9)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.7-arm64-darwin)
nokogiri (1.13.9-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.7-x86_64-darwin)
nokogiri (1.13.9-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.7-x86_64-linux)
nokogiri (1.13.9-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
orm_adapter (0.5.0)
@ -816,4 +816,4 @@ RUBY VERSION
ruby 3.0.4p208
BUNDLED WITH
2.3.18
2.3.16

View file

@ -1,13 +1,12 @@
# This Builder will create a contact inbox with specified attributes. If the contact inbox already exists, it will be returned.
# For Specific Channels like whatsapp, email etc . it smartly generated appropriate the source id when none is provided.
class ContactInboxBuilder
pattr_initialize [:contact_id!, :inbox_id!, :source_id]
pattr_initialize [:contact, :inbox, :source_id, { hmac_verified: false }]
def perform
@contact = Contact.find(contact_id)
@inbox = @contact.account.inboxes.find(inbox_id)
return unless ['Channel::TwilioSms', 'Channel::Sms', 'Channel::Email', 'Channel::Api', 'Channel::Whatsapp'].include? @inbox.channel_type
source_id = @source_id || generate_source_id
create_contact_inbox(source_id) if source_id.present?
@source_id ||= generate_source_id
create_contact_inbox if source_id.present?
end
private
@ -19,23 +18,37 @@ class ContactInboxBuilder
when 'Channel::Whatsapp'
wa_source_id
when 'Channel::Email'
@contact.email
email_source_id
when 'Channel::Sms'
@contact.phone_number
when 'Channel::Api'
phone_source_id
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
raise "Unsupported operation for this channel: #{@inbox.channel_type}"
end
end
def email_source_id
raise ActionController::ParameterMissing, 'contact email' unless @contact.email
@contact.email
end
def phone_source_id
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
def wa_source_id
return unless @contact.phone_number
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
# whatsapp doesn't want the + in e164 format
@contact.phone_number.delete('+').to_s
end
def twilio_source_id
return unless @contact.phone_number
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
case @inbox.channel.medium
when 'sms'
@ -45,11 +58,11 @@ class ContactInboxBuilder
end
end
def create_contact_inbox(source_id)
::ContactInbox.find_or_create_by!(
def create_contact_inbox
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: source_id
source_id: @source_id
)
end
end

View file

@ -1,25 +1,47 @@
class ContactBuilder
pattr_initialize [:source_id!, :inbox!, :contact_attributes!, :hmac_verified]
# This Builder will create a contact and contact inbox with specified attributes.
# If an existing identified contact exisits, it will be returned.
# for contact inbox logic it uses the contact inbox builder
class ContactInboxWithContactBuilder
pattr_initialize [:inbox!, :contact_attributes!, :source_id, :hmac_verified]
def perform
contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id)
return contact_inbox if contact_inbox
find_or_create_contact_and_contact_inbox
# in case of race conditions where contact is created by another thread
# we will try to find the contact and create a contact inbox
rescue ActiveRecord::RecordNotUnique
find_or_create_contact_and_contact_inbox
end
build_contact_inbox
def find_or_create_contact_and_contact_inbox
@contact_inbox = inbox.contact_inboxes.find_by(source_id: source_id) if source_id.present?
return @contact_inbox if @contact_inbox
ActiveRecord::Base.transaction(requires_new: true) do
build_contact_with_contact_inbox
update_contact_avatar(@contact) unless @contact.avatar.attached?
@contact_inbox
end
end
private
def build_contact_with_contact_inbox
@contact = find_contact || create_contact
@contact_inbox = create_contact_inbox
end
def account
@account ||= inbox.account
end
def create_contact_inbox(contact)
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: contact.id,
inbox_id: inbox.id,
source_id: source_id
)
def create_contact_inbox
ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: @source_id,
hmac_verified: hmac_verified
).perform
end
def update_contact_avatar(contact)
@ -61,16 +83,4 @@ class ContactBuilder
account.contacts.find_by(phone_number: phone_number)
end
def build_contact_inbox
ActiveRecord::Base.transaction do
contact = find_contact || create_contact
contact_inbox = create_contact_inbox(contact)
update_contact_avatar(contact)
contact_inbox
rescue StandardError => e
Rails.logger.error e
raise e
end
end
end

View file

@ -22,10 +22,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
return if @inbox.channel.reauthorization_required?
ActiveRecord::Base.transaction do
build_contact
build_contact_inbox
build_message
end
ensure_contact_avatar
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
rescue StandardError => e
@ -35,15 +34,12 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
private
def contact
@contact ||= @inbox.contact_inboxes.find_by(source_id: @sender_id)&.contact
end
def build_contact
return if contact.present?
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
def build_contact_inbox
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: @sender_id,
inbox: @inbox,
contact_attributes: contact_params
).perform
end
def build_message
@ -54,19 +50,11 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
end
def ensure_contact_avatar
return if contact_params[:remote_avatar_url].blank?
return if @contact.avatar.attached?
Avatar::AvatarFromUrlJob.perform_later(@contact, contact_params[:remote_avatar_url])
end
def conversation
@conversation ||= Conversation.find_by(conversation_params) || build_conversation
end
def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: @sender_id)
Conversation.create!(conversation_params.merge(
contact_inbox_id: @contact_inbox.id
))
@ -94,7 +82,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
account_id: @inbox.account_id,
inbox_id: @inbox.id,
contact_id: contact.id
contact_id: @contact_inbox.contact_id
}
end
@ -105,7 +93,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
message_type: @message_type,
content: response.content,
source_id: response.identifier,
sender: @outgoing_echo ? nil : contact
sender: @outgoing_echo ? nil : @contact_inbox.contact
}
end
@ -113,7 +101,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
avatar_url: result['profile_pic']
}
end

View file

@ -72,6 +72,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
def build_message
return if @outgoing_echo && already_sent_from_chatwoot?
return if message_content.blank? && all_unsupported_files?
@message = conversation.messages.create!(message_params)
@ -117,6 +118,13 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
cw_message.present?
end
def all_unsupported_files?
return if attachments.empty?
attachments_type = attachments.pluck(:type).uniq.first
unsupported_file_type?(attachments_type)
end
### Sample response
# {
# "object": "instagram",

View file

@ -35,7 +35,13 @@ class Messages::MessageBuilder
file: uploaded_attachment
)
attachment.file_type = file_type(uploaded_attachment&.content_type) if uploaded_attachment.is_a?(ActionDispatch::Http::UploadedFile)
attachment.file_type = if uploaded_attachment.is_a?(String)
file_type_by_signed_id(
uploaded_attachment
)
else
file_type(uploaded_attachment&.content_type)
end
end
end

View file

@ -2,7 +2,8 @@ class Messages::Messenger::MessageBuilder
include ::FileTypeHelper
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
# This check handles very rare case if there are multiple files to attach with only one usupported file
return if unsupported_file_type?(attachment['type'])
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
@ -80,4 +81,10 @@ class Messages::Messenger::MessageBuilder
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
{}
end
private
def unsupported_file_type?(attachment_type)
[:template, :unsupported_type].include? attachment_type.to_sym
end
end

View file

@ -5,9 +5,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :set_current_page, only: [:index]
def index
@articles_count = @portal.articles.count
@articles = @portal.articles
@articles = @articles.search(list_params) if list_params.present?
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.page(@current_page)
end
def create
@ -37,7 +38,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
end
def portal
@portal ||= Current.account.portals.find_by(slug: params[:portal_id])
@portal ||= Current.account.portals.find_by!(slug: params[:portal_id])
end
def article_params

View file

@ -5,6 +5,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
before_action :set_current_page, only: [:index]
def index
@current_locale = params[:locale]
@categories = @portal.categories.search(params)
end

View file

@ -2,8 +2,11 @@ class Api::V1::Accounts::Contacts::ContactInboxesController < Api::V1::Accounts:
before_action :ensure_inbox, only: [:create]
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ContactInbox.create!(contact: @contact, inbox: @inbox, source_id: source_id)
@contact_inbox = ContactInboxBuilder.new(
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
end
private

View file

@ -134,8 +134,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
return if params[:inbox_id].blank?
inbox = Current.account.inboxes.find(params[:inbox_id])
source_id = params[:source_id] || SecureRandom.uuid
ContactInbox.create!(contact: @contact, inbox: inbox, source_id: source_id)
ContactInboxBuilder.new(
contact: @contact,
inbox: inbox,
source_id: params[:source_id]
).perform
end
def permitted_params

View file

@ -3,7 +3,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create, :filter]
before_action :contact_inbox, only: [:create]
before_action :inbox, :contact, :contact_inbox, only: [:create]
def index
result = conversation_finder.perform
@ -109,22 +109,35 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
authorize @conversation.inbox, :show?
end
def inbox
return if params[:inbox_id].blank?
@inbox = Current.account.inboxes.find(params[:inbox_id])
authorize @inbox, :show?
end
def contact
return if params[:contact_id].blank?
@contact = Current.account.contacts.find(params[:contact_id])
end
def contact_inbox
@contact_inbox = build_contact_inbox
# fallback for the old case where we do look up only using source id
# In future we need to change this and make sure we do look up on combination of inbox_id and source_id
# and deprecate the support of passing only source_id as the param
@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?
return if @inbox.blank? || @contact.blank?
ContactInboxBuilder.new(
contact_id: params[:contact_id],
inbox_id: inbox.id,
contact: @contact,
inbox: @inbox,
source_id: params[:source_id]
).perform
end

View file

@ -14,6 +14,8 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
render json: { error: @macro.errors.messages }, status: :unprocessable_entity and return unless @macro.valid?
@macro.save!
process_attachments
@macro
end
def show
@ -25,10 +27,21 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
def attach_file
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
end
def update
ActiveRecord::Base.transaction do
@macro.update!(macros_with_user)
@macro.set_visibility(current_user, permitted_params)
process_attachments
@macro.save!
rescue StandardError => e
Rails.logger.error e
@ -42,6 +55,17 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
head :ok
end
def process_attachments
actions = @macro.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@macro.files.attach(blob)
end
end
def permitted_params
params.permit(
:name, :account_id, :visibility,

View file

@ -14,7 +14,10 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
@portal.members << agents
end
def show; end
def show
@all_articles = @portal.articles
@articles = @all_articles.search(locale: params[:locale])
end
def create
@portal = Current.account.portals.build(portal_params)

View file

@ -17,7 +17,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
@contact = ContactIdentifyAction.new(
contact: @contact,
params: { email: contact_email, phone_number: contact_phone_number, name: contact_name },
retain_original_contact_name: true
retain_original_contact_name: true,
discard_invalid_attrs: true
).perform
end

View file

@ -13,6 +13,8 @@ module RequestExceptionHandler
render_not_found_error('Resource could not be found')
rescue Pundit::NotAuthorizedError
render_unauthorized('You are not authorized to do this action')
rescue ActionController::ParameterMissing => e
render_could_not_create_error(e.message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset

View file

@ -11,7 +11,7 @@ class DeviseOverrides::PasswordsController < Devise::PasswordsController
@recoverable = User.find_by(reset_password_token: reset_password_token)
if @recoverable && reset_password_and_confirmation(@recoverable)
send_auth_headers(@recoverable)
render partial: 'devise/auth.json', locals: { resource: @recoverable }
render partial: 'devise/auth', formats: [:json], locals: { resource: @recoverable }
else
render json: { message: 'Invalid token', redirect_url: '/' }, status: :unprocessable_entity
end

View file

@ -3,16 +3,12 @@ class Platform::Api::V1::AccountsController < PlatformController
@resource = Account.new(account_params)
@resource.save!
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
render json: @resource
end
def show
render json: @resource
end
def show; end
def update
@resource.update!(account_params)
render json: @resource
end
def destroy
@ -27,6 +23,14 @@ class Platform::Api::V1::AccountsController < PlatformController
end
def account_params
params.permit(:name, :locale)
if permitted_params[:enabled_features]
return permitted_params.except(:enabled_features).merge(selected_feature_flags: permitted_params[:enabled_features].map(&:to_sym))
end
permitted_params
end
def permitted_params
params.permit(:name, :locale, enabled_features: [], limits: {})
end
end

View file

@ -4,7 +4,7 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
def create
source_id = params[:source_id] || SecureRandom.uuid
@contact_inbox = ::ContactBuilder.new(
@contact_inbox = ::ContactInboxWithContactBuilder.new(
source_id: source_id,
inbox: @inbox_channel.inbox,
contact_attributes: permitted_params.except(:identifier, :identifier_hash)

View file

@ -1,7 +1,7 @@
class Public::Api::V1::Portals::ArticlesController < PublicController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category
before_action :set_category, except: [:index]
before_action :set_article, only: [:show]
layout 'portal'
@ -20,7 +20,7 @@ class Public::Api::V1::Portals::ArticlesController < PublicController
end
def set_category
@category = @portal.categories.find_by!(slug: params[:category_slug])
@category = @portal.categories.find_by!(slug: params[:category_slug]) if params[:category_slug].present?
end
def portal

View file

@ -8,6 +8,12 @@ module FileTypeHelper
:file
end
# Used in case of DIRECT_UPLOADS_ENABLED=true
def file_type_by_signed_id(signed_id)
blob = ActiveStorage::Blob.find_signed(signed_id)
file_type(blob&.content_type)
end
def image_file?(content_type)
[
'image/jpeg',

View file

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

View file

@ -7,8 +7,8 @@ class CategoriesAPI extends PortalsAPI {
super('categories', { accountScoped: true });
}
get({ portalSlug }) {
return axios.get(`${this.url}/${portalSlug}/categories`);
get({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}/categories?locale=${locale}`);
}
create({ portalSlug, categoryObj }) {

View file

@ -6,6 +6,10 @@ class PortalsAPI extends ApiClient {
super('portals', { accountScoped: true });
}
getPortal({ portalSlug, locale }) {
return axios.get(`${this.url}/${portalSlug}?locale=${locale}`);
}
updatePortal({ portalSlug, portalObj }) {
return axios.patch(`${this.url}/${portalSlug}`, portalObj);
}

View file

@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class MacrosAPI extends ApiClient {
constructor() {
super('macros', { accountScoped: true });
}
executeMacro({ macroId, conversationIds }) {
return axios.post(`${this.url}/${macroId}/execute`, {
conversation_ids: conversationIds,
});
}
}
export default new MacrosAPI();

View file

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

View file

@ -0,0 +1,14 @@
import macros from '../macros';
import ApiClient from '../ApiClient';
describe('#macrosAPI', () => {
it('creates correct instance', () => {
expect(macros).toBeInstanceOf(ApiClient);
expect(macros).toHaveProperty('get');
expect(macros).toHaveProperty('create');
expect(macros).toHaveProperty('update');
expect(macros).toHaveProperty('delete');
expect(macros).toHaveProperty('show');
expect(macros.url).toBe('/api/v1/macros');
});
});

View file

@ -113,9 +113,22 @@ $default-button-height: 4.0rem;
}
&.clear {
color: var(--w-700);
&.secondary {
color: var(--s-700)
}
&.success {
color: var(--g-700)
}
&.alert {
color: var(--r-700)
}
&.warning {
color: var(--y-600);
color: var(--y-700)
}
&:hover {
@ -146,6 +159,8 @@ $default-button-height: 4.0rem;
&.small {
height: var(--space-large);
padding-bottom: var(--space-smaller);
padding-top: var(--space-smaller);
}
&.large {

View file

@ -14,15 +14,9 @@
}
.modal--close {
border-radius: 50%;
color: $color-heading;
cursor: pointer;
font-size: $font-size-big;
line-height: $space-normal;
padding: $space-normal;
position: absolute;
right: $space-micro;
top: $space-micro;
right: $space-small;
top: $space-small;
&:hover {
background: $color-background;

View file

@ -7,9 +7,13 @@
@click="onBackDropClick"
>
<div :class="modalContainerClassName" @click.stop>
<button class="modal--close" @click="close">
<fluent-icon icon="dismiss" />
</button>
<woot-button
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="modal--close"
@click="close"
/>
<slot />
</div>
</div>

View file

@ -5,9 +5,12 @@ import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import ConfirmDeleteModal from './widgets/modal/ConfirmDeleteModal.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import Label from './ui/Label';
@ -21,8 +24,6 @@ import SubmitButton from './buttons/FormSubmitButton';
import Tabs from './ui/Tabs/Tabs';
import TabsItem from './ui/Tabs/TabsItem';
import Thumbnail from './widgets/Thumbnail.vue';
import ConfirmModal from './widgets/modal/ConfirmationModal.vue';
import ContextMenu from './ui/ContextMenu.vue';
const WootUIKit = {
AvatarUploader,
@ -31,9 +32,12 @@ const WootUIKit = {
Code,
ColorPicker,
ConfirmDeleteModal,
ConfirmModal,
ContextMenu,
DeleteModal,
DropdownItem,
DropdownMenu,
FeatureToggle,
HorizontalBar,
Input,
Label,
@ -47,8 +51,6 @@ const WootUIKit = {
Tabs,
TabsItem,
Thumbnail,
ConfirmModal,
ContextMenu,
install(Vue) {
const keys = Object.keys(this);
keys.pop(); // remove 'install' from keys

View file

@ -73,14 +73,14 @@ export default {
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
inboxes: 'inboxes/getInboxes',
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
isACustomBrandedInstance: 'globalConfig/isACustomBrandedInstance',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
labels: 'labels/getLabelsOnSidebar',
teams: 'teams/getMyTeams',
}),

View file

@ -39,7 +39,7 @@ const primaryMenuItems = accountId => [
label: 'HELP_CENTER.TITLE',
featureFlag: 'help_center',
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'list_all_portals',
toStateName: 'default_portal_articles',
roles: ['administrator'],
},
{

View file

@ -1,45 +1,58 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import { frontendURL } from '../../../../helper/URLHelper';
const settings = accountId => ({
parentNav: 'settings',
routes: [
'agent_bots',
'agent_list',
'canned_list',
'labels_list',
'settings_inbox',
'attributes_list',
'settings_inbox_new',
'settings_inbox_list',
'settings_inbox_show',
'settings_inboxes_page_channel',
'settings_inboxes_add_agents',
'settings_inbox_finish',
'settings_integrations',
'settings_integrations_webhook',
'settings_integrations_integration',
'settings_applications',
'settings_integrations_dashboard_apps',
'settings_applications_webhook',
'settings_applications_integration',
'general_settings',
'automation_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
'general_settings',
'labels_list',
'macros_edit',
'macros_new',
'macros_wrapper',
'settings_applications_integration',
'settings_applications_webhook',
'settings_applications',
'settings_inbox_finish',
'settings_inbox_list',
'settings_inbox_new',
'settings_inbox_show',
'settings_inbox',
'settings_inboxes_add_agents',
'settings_inboxes_page_channel',
'settings_integrations_dashboard_apps',
'settings_integrations_integration',
'settings_integrations_webhook',
'settings_integrations',
'settings_teams_add_agents',
'settings_teams_edit_finish',
'settings_teams_edit_members',
'settings_teams_edit',
'settings_teams_finish',
'settings_teams_list',
'settings_teams_new',
'settings_teams_add_agents',
'settings_teams_finish',
'settings_teams_edit',
'settings_teams_edit_members',
'settings_teams_edit_finish',
'billing_settings_index',
'automation_list',
],
menuItems: [
{
icon: 'briefcase',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
{
icon: 'people',
label: 'AGENTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
icon: 'people-team',
@ -47,6 +60,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
icon: 'mail-inbox-all',
@ -54,6 +68,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{
icon: 'tag',
@ -61,6 +76,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
featureFlag: FEATURE_FLAGS.LABELS,
},
{
icon: 'code',
@ -70,13 +86,34 @@ const settings = accountId => ({
`accounts/${accountId}/settings/custom-attributes/list`
),
toStateName: 'attributes_list',
featureFlag: FEATURE_FLAGS.CUSTOM_ATTRIBUTES,
},
{
icon: 'automation',
label: 'AUTOMATION',
beta: true,
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
},
{
icon: 'bot',
label: 'AGENT_BOTS',
beta: true,
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
featureFlag: FEATURE_FLAGS.AGENT_BOTS,
},
{
icon: 'flash-settings',
label: 'MACROS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
beta: true,
featureFlag: FEATURE_FLAGS.MACROS,
},
{
icon: 'chat-multiple',
@ -86,6 +123,7 @@ const settings = accountId => ({
`accounts/${accountId}/settings/canned-response/list`
),
toStateName: 'canned_list',
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
},
{
icon: 'flash-on',
@ -93,6 +131,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'star-emphasis',
@ -100,6 +139,7 @@ const settings = accountId => ({
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'credit-card-person',
@ -109,13 +149,6 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'settings',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
],
});

View file

@ -20,6 +20,8 @@
import { frontendURL } from '../../../helper/URLHelper';
import SecondaryNavItem from './SecondaryNavItem.vue';
import AccountContext from './AccountContext.vue';
import { mapGetters } from 'vuex';
import { FEATURE_FLAGS } from '../../../featureFlags';
export default {
components: {
@ -61,6 +63,9 @@ export default {
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSecondaryMenu() {
return this.menuConfig.menuItems && this.menuConfig.menuItems.length;
},
@ -89,7 +94,7 @@ export default {
icon: 'folder',
label: 'INBOXES',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.INBOX_MANAGEMENT),
newLinkTag: 'NEW_INBOX',
key: 'inbox',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
@ -117,7 +122,7 @@ export default {
icon: 'number-symbol',
label: 'LABELS',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
key: 'label',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@ -141,7 +146,7 @@ export default {
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
@ -163,7 +168,7 @@ export default {
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: true,
newLink: true,
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_TEAM',
key: 'team',
toState: frontendURL(`accounts/${this.accountId}/settings/teams/new`),
@ -238,6 +243,9 @@ export default {
toggleAccountModal() {
this.$emit('toggle-accounts');
},
showNewLink(featureFlag) {
return this.isFeatureEnabledonAccount(this.accountId, featureFlag);
},
},
};
</script>

View file

@ -26,7 +26,7 @@
:class="{ 'text-truncate': shouldTruncate }"
>
{{ label }}
<span v-if="isHelpCenterSidebar && childItemCount" class="count-view">
<span v-if="showChildCount" class="count-view">
{{ childItemCount }}
</span>
</span>
@ -76,7 +76,7 @@ export default {
type: String,
default: '',
},
isHelpCenterSidebar: {
showChildCount: {
type: Boolean,
default: false,
},
@ -127,11 +127,16 @@ $label-badge-size: var(--space-slab);
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-500);
}
}
.menu-label {
flex-grow: 1;
line-height: var(--space-two);
display: inline-flex;
align-items: center;
}
.inbox-icon {
@ -175,10 +180,6 @@ $label-badge-size: var(--space-slab);
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
line-height: var(--font-size-small);
}
</style>

View file

@ -1,19 +1,18 @@
<template>
<li class="sidebar-item">
<li v-show="isMenuItemVisible" class="sidebar-item">
<div v-if="hasSubMenu" class="secondary-menu--wrap">
<span class="secondary-menu--header fs-small">
{{ $t(`SIDEBAR.${menuItem.label}`) }}
</span>
<div v-if="isHelpCenterSidebar" class="submenu-icons">
<div v-if="menuItem.showNewButton" class="submenu-icons">
<woot-button
size="tiny"
variant="clear"
color-scheme="secondary"
icon="add"
class="submenu-icon"
@click="onClickOpen"
>
<fluent-icon icon="add" size="16" />
</woot-button>
/>
</div>
</div>
<router-link
@ -28,15 +27,11 @@
size="14"
/>
{{ $t(`SIDEBAR.${menuItem.label}`) }}
<span
v-if="isHelpCenterSidebar"
class="count-view"
:class="computedClass"
>
<span v-if="showChildCount(menuItem.count)" class="count-view">
{{ `${menuItem.count}` }}
</span>
<span
v-if="menuItem.label === 'AUTOMATION'"
v-if="menuItem.beta"
data-view-component="true"
label="Beta"
class="beta"
@ -55,7 +50,7 @@
:should-truncate="child.truncateLabel"
:icon="computedInboxClass(child)"
:warning-icon="computedInboxErrorClass(child)"
:is-help-center-sidebar="isHelpCenterSidebar"
:show-child-count="showChildCount(child.count)"
:child-item-count="child.count"
/>
<router-link
@ -64,10 +59,10 @@
:to="menuItem.toState"
custom
>
<li>
<li class="menu-item--new">
<a
:href="href"
class="button small clear menu-item--new secondary"
class="button small link clear secondary"
:class="{ 'is-active': isActive }"
@click="e => newLinkClick(e, navigate)"
>
@ -78,9 +73,6 @@
</a>
</li>
</router-link>
<p v-if="isHelpCenterSidebar && isCategoryEmpty" class="empty-text">
{{ $t('SIDEBAR.HELP_CENTER.CATEGORY_EMPTY_MESSAGE') }}
</p>
</ul>
</li>
</template>
@ -104,20 +96,25 @@ export default {
type: Object,
default: () => ({}),
},
isHelpCenterSidebar: {
type: Boolean,
default: false,
},
isCategoryEmpty: {
type: Boolean,
default: false,
},
},
computed: {
...mapGetters({ activeInbox: 'getSelectedInbox' }),
...mapGetters({
activeInbox: 'getSelectedInbox',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
hasSubMenu() {
return !!this.menuItem.children;
},
isMenuItemVisible() {
if (!this.menuItem.featureFlag) {
return true;
}
return this.isFeatureEnabledonAccount(
this.accountId,
this.menuItem.featureFlag
);
},
isInboxConversation() {
return (
this.$store.state.route.name === 'inbox_conversation' &&
@ -148,8 +145,8 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isArticlesView() {
return this.$store.state.route.name === this.menuItem.toStateName;
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
computedClass() {
@ -168,12 +165,11 @@ export default {
}
return ' ';
}
if (this.isHelpCenterSidebar) {
if (this.isArticlesView) {
return 'is-active';
}
return ' ';
if (this.isCurrentRoute) {
return 'is-active';
}
return '';
},
},
@ -204,11 +200,14 @@ export default {
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
return this.isAdmin && !!item.newLink;
},
onClickOpen() {
this.$emit('open');
},
showChildCount(count) {
return Number.isInteger(count);
},
},
};
</script>
@ -264,6 +263,11 @@ export default {
color: var(--w-500);
border-color: var(--w-25);
}
&.is-active .count-view {
background: var(--w-75);
color: var(--w-600);
}
}
.secondary-menu--icon {
@ -293,22 +297,19 @@ export default {
top: -1px;
}
.sidebar-item .button.menu-item--new {
display: inline-flex;
height: var(--space-medium);
margin: var(--space-smaller) 0;
padding: var(--space-smaller);
color: var(--s-500);
.sidebar-item .menu-item--new {
padding: var(--space-small) 0;
&:hover {
color: var(--w-500);
.button {
display: inline-flex;
color: var(--s-500);
}
}
.beta {
padding-right: var(--space-smaller) !important;
padding-left: var(--space-smaller) !important;
margin-left: var(--space-half) !important;
margin-left: var(--space-smaller) !important;
display: inline-block;
font-size: var(--font-size-micro);
font-weight: var(--font-weight-medium);
@ -327,11 +328,6 @@ export default {
font-weight: var(--font-weight-bold);
margin-left: var(--space-smaller);
padding: var(--space-zero) var(--space-smaller);
&.is-active {
background: var(--w-50);
color: var(--w-500);
}
}
.submenu-icons {
@ -343,10 +339,4 @@ export default {
margin-left: var(--space-small);
}
}
.empty-text {
color: var(--s-500);
font-size: var(--font-size-small);
margin: var(--space-smaller);
}
</style>

View file

@ -1,17 +1,15 @@
<template>
<span class="time-ago">
<span> {{ timeAgo }}</span>
<span>{{ timeAgo }}</span>
</span>
</template>
<script>
const ZERO = 0;
const MINUTE_IN_MILLI_SECONDS = 60000;
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
import timeMixin from 'dashboard/mixins/time';
import { differenceInMilliseconds } from 'date-fns';
export default {
name: 'TimeAgo',
@ -28,51 +26,40 @@ export default {
},
data() {
return {
timeAgo: '',
timeAgo: this.dynamicTime(this.timestamp),
timer: null,
};
},
computed: {
watch: {
timestamp() {
this.timeAgo = this.dynamicTime(this.timestamp);
},
},
mounted() {
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
clearTimeout(this.timer);
},
methods: {
createTimer() {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, this.refreshTime());
},
refreshTime() {
const timeDiff = differenceInMilliseconds(
new Date(),
new Date(this.timestamp * 1000)
);
const timeDiff = Date.now() - this.timestamp * 1000;
if (timeDiff > DAY_IN_MILLI_SECONDS) {
return DAY_IN_MILLI_SECONDS;
}
if (timeDiff > HOUR_IN_MILLI_SECONDS) {
return HOUR_IN_MILLI_SECONDS;
}
if (timeDiff > MINUTE_IN_MILLI_SECONDS) {
return MINUTE_IN_MILLI_SECONDS;
}
return ZERO;
},
},
mounted() {
this.timeAgo = this.dynamicTime(this.timestamp);
if (this.isAutoRefreshEnabled) {
this.createTimer();
}
},
beforeDestroy() {
this.clearTimer();
},
methods: {
createTimer() {
const refreshTime = this.refreshTime;
if (refreshTime > ZERO) {
this.timer = setTimeout(() => {
this.timeAgo = this.dynamicTime(this.timestamp);
this.createTimer();
}, refreshTime);
}
},
clearTimer() {
if (this.timer) {
clearTimeout(this.timer);
}
return MINUTE_IN_MILLI_SECONDS;
},
},
};

View file

@ -1,8 +1,5 @@
<template>
<div
class="filter"
:class="{ error: v.action_params.$dirty && v.action_params.$error }"
>
<div class="filter" :class="actionInputStyles">
<div class="filter-inputs">
<select
v-model="action_name"
@ -60,6 +57,7 @@
</div>
</div>
<woot-button
v-if="!isMacro"
icon="dismiss"
variant="clear"
color-scheme="secondary"
@ -120,6 +118,10 @@ export default {
type: String,
default: '',
},
isMacro: {
type: Boolean,
default: false,
},
},
computed: {
action_name: {
@ -146,6 +148,12 @@ export default {
return this.actionTypes.find(action => action.key === this.action_name)
.inputType;
},
actionInputStyles() {
return {
'has-error': this.v.action_params.$dirty && this.v.action_params.$error,
'is-a-macro': this.isMacro,
};
},
},
methods: {
removeAction() {
@ -165,9 +173,21 @@ export default {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-medium);
margin-bottom: var(--space-small);
&.is-a-macro {
margin-bottom: 0;
background: var(--white);
padding: var(--space-zero);
border: unset;
border-radius: unset;
}
}
.filter.error {
.no-margin-bottom {
margin-bottom: 0;
}
.filter.has-error {
background: var(--r-50);
}

View file

@ -113,5 +113,6 @@ input[type='file'] {
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
margin-bottom: 0;
}
</style>

View file

@ -1,10 +1,6 @@
<template>
<div
class="avatar-container"
:style="[style, customStyle]"
aria-hidden="true"
>
<span>{{ userInitial }}</span>
<div class="avatar-container" :style="style" aria-hidden="true">
{{ userInitial }}
</div>
</template>
@ -16,69 +12,26 @@ export default {
type: String,
default: '',
},
backgroundColor: {
type: String,
default: '#c2e1ff',
},
color: {
type: String,
default: '#1976cc',
},
customStyle: {
type: Object,
default: undefined,
},
size: {
type: Number,
default: 40,
},
src: {
type: String,
default: '',
},
rounded: {
type: Boolean,
default: true,
},
variant: {
type: String,
default: 'circle',
},
},
computed: {
style() {
let style = {
width: `${this.size}px`,
height: `${this.size}px`,
borderRadius:
this.variant === 'square' ? 'var(--border-radius-large)' : '50%',
lineHeight: `${this.size + Math.floor(this.size / 20)}px`,
return {
fontSize: `${Math.floor(this.size / 2.5)}px`,
};
if (this.backgroundColor) {
style = { ...style, backgroundColor: this.backgroundColor };
}
if (this.color) {
style = { ...style, color: this.color };
}
return style;
},
userInitial() {
return this.initials || this.initial(this.username);
},
},
methods: {
initial(username) {
const parts = username ? username.split(/[ -]/) : [];
let initials = '';
for (let i = 0; i < parts.length; i += 1) {
initials += parts[i].charAt(0);
}
const parts = this.username.split(/[ -]/);
let initials = parts.reduce((acc, curr) => acc + curr.charAt(0), '');
if (initials.length > 2 && initials.search(/[A-Z]/) !== -1) {
initials = initials.replace(/[a-z]+/g, '');
}
initials = initials.substr(0, 2).toUpperCase();
initials = initials.substring(0, 2).toUpperCase();
return initials;
},
},
@ -88,6 +41,7 @@ export default {
<style lang="scss" scoped>
.avatar-container {
display: flex;
line-height: 100%;
font-weight: 500;
align-items: center;
justify-content: center;

View file

@ -0,0 +1,25 @@
<template>
<div v-if="isFeatureEnabled">
<slot />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
props: {
featureKey: {
type: String,
required: true,
},
},
computed: {
...mapGetters({
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
accountId: 'getCurrentAccountId',
}),
isFeatureEnabled() {
return this.isFeatureEnabledonAccount(this.accountId, this.featureKey);
},
},
};
</script>

View file

@ -83,75 +83,71 @@ export default {
},
pageSize: {
type: Number,
default: 15,
default: 25,
},
totalCount: {
type: Number,
default: 0,
},
onPageChange: {
type: Function,
default: () => {},
},
},
computed: {
isFooterVisible() {
return this.totalCount && !(this.firstIndex > this.totalCount);
},
firstIndex() {
const firstIndex = this.pageSize * (this.currentPage - 1) + 1;
return firstIndex;
return this.pageSize * (this.currentPage - 1) + 1;
},
lastIndex() {
const index = Math.min(this.totalCount, this.pageSize * this.currentPage);
return index;
return Math.min(this.totalCount, this.pageSize * this.currentPage);
},
searchButtonClass() {
return this.searchQuery !== '' ? 'show' : '';
},
hasLastPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
return !!Math.ceil(this.totalCount / this.pageSize);
},
hasFirstPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
return this.currentPage === 1;
},
hasNextPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
return this.currentPage === Math.ceil(this.totalCount / this.pageSize);
},
hasPrevPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
return this.currentPage === 1;
},
},
methods: {
onNextPage() {
if (this.hasNextPage) return;
if (this.hasNextPage) {
return;
}
const newPage = this.currentPage + 1;
this.onPageChange(newPage);
},
onPrevPage() {
if (this.hasPrevPage) return;
if (this.hasPrevPage) {
return;
}
const newPage = this.currentPage - 1;
this.onPageChange(newPage);
},
onFirstPage() {
if (this.hasFirstPage) return;
if (this.hasFirstPage) {
return;
}
const newPage = 1;
this.onPageChange(newPage);
},
onLastPage() {
if (this.hasLastPage) return;
if (this.hasLastPage) {
return;
}
const newPage = Math.ceil(this.totalCount / this.pageSize);
this.onPageChange(newPage);
},
onPageChange(page) {
this.$emit('page-change', page);
},
},
};
</script>

View file

@ -2,8 +2,8 @@ import { mount } from '@vue/test-utils';
import Avatar from './Avatar.vue';
import Thumbnail from './Thumbnail.vue';
describe(`when there are NO errors loading the thumbnail`, () => {
it(`should render the agent thumbnail`, () => {
describe('Thumbnail.vue', () => {
it('should render the agent thumbnail if valid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_valid_url.com',
@ -14,14 +14,12 @@ describe(`when there are NO errors loading the thumbnail`, () => {
};
},
});
expect(wrapper.find('#image').exists()).toBe(true);
expect(wrapper.find('.user-thumbnail').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(false);
});
});
describe(`when there ARE errors loading the thumbnail`, () => {
it(`should render the agent avatar`, () => {
it('should render the avatar component if invalid image is passed', () => {
const wrapper = mount(Thumbnail, {
propsData: {
src: 'https://some_invalid_url.com',
@ -32,19 +30,17 @@ describe(`when there ARE errors loading the thumbnail`, () => {
};
},
});
expect(wrapper.find('#image').exists()).toBe(false);
expect(wrapper.find('.avatar-container').exists()).toBe(true);
const avatarComponent = wrapper.findComponent(Avatar);
expect(avatarComponent.exists()).toBe(true);
});
});
describe(`when Avatar shows`, () => {
it(`initials shold correspond to username`, () => {
it('should the initial of the name if no image is passed', () => {
const wrapper = mount(Avatar, {
propsData: {
username: 'Angie Rojas',
},
});
expect(wrapper.find('span').text()).toBe('AR');
expect(wrapper.find('div').text()).toBe('AR');
});
});

View file

@ -1,74 +1,23 @@
<template>
<div class="user-thumbnail-box" :style="{ height: size, width: size }">
<img
v-if="!imgError && Boolean(src)"
id="image"
v-if="!imgError && src"
:src="src"
:class="thumbnailClass"
@error="onImgError()"
@error="onImgError"
/>
<Avatar
v-else
:username="userNameWithoutEmoji"
:class="thumbnailClass"
:size="avatarSize"
:variant="variant"
/>
<img
v-if="badge === 'instagram_direct_message'"
id="badge"
v-if="badgeSrc"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/instagram-dm.png"
/>
<img
v-else-if="badge === 'facebook'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/messenger.png"
/>
<img
v-else-if="badge === 'twitter-tweet'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/twitter-tweet.png"
/>
<img
v-else-if="badge === 'twitter-dm'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/twitter-dm.png"
/>
<img
v-else-if="badge === 'whatsapp'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/whatsapp.png"
/>
<img
v-else-if="badge === 'sms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/sms.png"
/>
<img
v-else-if="badge === 'Channel::Line'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/line.png"
/>
<img
v-else-if="badge === 'Channel::Telegram'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="/integrations/channels/badges/telegram.png"
:src="`/integrations/channels/badges/${badgeSrc}.png`"
alt="Badge"
/>
<div
v-if="showStatusIndicator"
@ -83,7 +32,7 @@
* Src - source for round image
* Size - Size of the thumbnail
* Badge - Chat source indication { fb / telegram }
* Username - User name for avatar
* Username - Username for avatar
*/
import Avatar from './Avatar';
import { removeEmoji } from 'shared/helpers/emoji';
@ -103,7 +52,7 @@ export default {
},
badge: {
type: String,
default: 'fb',
default: '',
},
username: {
type: String,
@ -142,6 +91,19 @@ export default {
avatarSize() {
return Number(this.size.replace(/\D+/g, ''));
},
badgeSrc() {
return {
instagram_direct_message: 'instagram-dm',
facebook: 'messenger',
'twitter-tweet': 'twitter-tweet',
'twitter-dm': 'twitter-dm',
whatsapp: 'whatsapp',
sms: 'sms',
'Channel::Line': 'line',
'Channel::Telegram': 'telegram',
'Channel::WebWidget': '',
}[this.badge];
},
badgeStyle() {
const size = Math.floor(this.avatarSize / 3);
const badgeSize = `${size + 2}px`;
@ -160,12 +122,10 @@ export default {
},
},
watch: {
src: {
handler(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
src(value, oldValue) {
if (value !== oldValue && this.imgError) {
this.imgError = false;
}
},
},
methods: {
@ -229,9 +189,5 @@ export default {
.user-online-status--offline {
background: var(--s-500);
}
.user-online-status--offline {
background: var(--s-500);
}
}
</style>

View file

@ -11,7 +11,6 @@
size="small"
@click="toggleEmojiPicker"
/>
<!-- ensure the same validations for attachment types are implemented in backend models as well -->
<file-upload
ref="upload"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
@ -47,6 +46,16 @@
:title="$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_ICON')"
@click="toggleAudioRecorder"
/>
<woot-button
v-if="showEditorToggle"
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_FORMAT_ICON')"
icon="quote"
emoji="🖊️"
color-scheme="secondary"
variant="smooth"
size="small"
@click="$emit('toggle-editor')"
/>
<woot-button
v-if="showAudioPlayStopButton"
:icon="audioRecorderPlayStopIcon"
@ -110,13 +119,15 @@ import { hasPressedAltAndAKey } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import inboxMixin from 'shared/mixins/inboxMixin';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
} from 'shared/constants/messages';
import { REPLY_EDITOR_MODES } from './constants';
import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload },
@ -182,7 +193,7 @@ export default {
type: Boolean,
default: false,
},
isFormatMode: {
showEditorToggle: {
type: Boolean,
default: false,
},
@ -200,6 +211,10 @@ export default {
},
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
isNote() {
return this.mode === REPLY_EDITOR_MODES.NOTE;
},
@ -217,7 +232,12 @@ export default {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
return this.showAudioRecorder;
return (
this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.VOICE_RECORDER
) && this.showAudioRecorder
);
},
showAudioPlayStopButton() {
return this.showAudioRecorder && this.isRecordingAudio;

View file

@ -420,6 +420,8 @@ export default {
<style lang="scss">
.wrap {
> .bubble {
min-width: 128px;
&.is-image,
&.is-video {
padding: 0;

View file

@ -139,7 +139,6 @@ export default {
listLoadingStatus: 'getAllMessagesLoaded',
getUnreadCount: 'getUnreadCount',
loadingChatList: 'getChatListLoadingStatus',
conversationLastSeen: 'getConversationLastSeen',
}),
inboxId() {
return this.currentChat.inbox_id;
@ -234,7 +233,6 @@ export default {
return 'arrow-chevron-left';
},
getLastSeenAt() {
if (this.conversationLastSeen) return this.conversationLastSeen;
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
},
@ -434,7 +432,7 @@ export default {
&::before {
transform: rotate(0deg);
left: var(--space-half);
left: var(--space-smaller);
bottom: var(--space-minus-slab);
}
}

View file

@ -109,10 +109,11 @@
:recording-audio-state="recordingAudioState"
:is-recording-audio="isRecordingAudio"
:is-on-private-note="isOnPrivateNote"
:is-format-mode="showRichContentEditor"
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:enable-multiple-file-upload="enableMultipleFileUpload"
:has-whatsapp-templates="hasWhatsappTemplates"
@selectWhatsappTemplate="openWhatsappTemplateModal"
@toggle-editor="toggleRichContentEditor"
/>
<whatsapp-templates
:inbox-id="inbox.id"
@ -230,6 +231,13 @@ export default {
return true;
}
if (this.isAPIInbox) {
const {
display_rich_content_editor: displayRichContentEditor = false,
} = this.uiSettings;
return displayRichContentEditor;
}
return false;
},
assignedAgent: {
@ -365,7 +373,7 @@ export default {
);
},
isRichEditorEnabled() {
return this.isAWebWidgetInbox || this.isAnEmailChannel || this.isAPIInbox;
return this.isAWebWidgetInbox || this.isAnEmailChannel;
},
showAudioRecorder() {
return !this.isOnPrivateNote && this.showFileUpload;
@ -477,7 +485,7 @@ export default {
const hasNextWord = updatedMessage.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.mentionSearchKey = updatedMessage.substr(1, updatedMessage.length);
this.mentionSearchKey = updatedMessage.substring(1);
this.showMentions = true;
} else {
this.mentionSearchKey = '';
@ -511,6 +519,11 @@ export default {
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
toggleRichContentEditor() {
this.updateUISettings({
display_rich_content_editor: !this.showRichContentEditor,
});
},
getSavedDraftMessages() {
return LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {};
},
@ -970,7 +983,7 @@ export default {
&::before {
transform: rotate(0deg);
left: var(--space-half);
left: var(--space-smaller);
bottom: var(--space-minus-slab);
}
}

View file

@ -17,7 +17,10 @@
@click="snoozeConversation(option.snoozedUntil)"
/>
</menu-item-with-submenu>
<menu-item-with-submenu :option="labelMenuConfig">
<menu-item-with-submenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<template>
<menu-item
v-for="label in labels"
@ -28,7 +31,10 @@
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="agentMenuConfig">
<menu-item-with-submenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<agent-loading-placeholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<menu-item
@ -40,7 +46,10 @@
/>
</template>
</menu-item-with-submenu>
<menu-item-with-submenu :option="teamMenuConfig">
<menu-item-with-submenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<menu-item
v-for="team in teams"
:key="team.id"
@ -141,9 +150,19 @@ export default {
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
}),
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
);
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.$store.getters['inboxAssignableAgents/getAssignableAgents'](
this.inboxId
),
];
},
},
mounted() {

View file

@ -18,7 +18,7 @@
size="20px"
class="agent-thumbnail"
/>
<p class="menu-label truncate-text">{{ option.label }}</p>
<p class="menu-label text-truncate">{{ option.label }}</p>
</div>
</template>
@ -50,7 +50,6 @@ export default {
padding: var(--space-smaller);
border-radius: var(--border-radius-small);
overflow: hidden;
.menu-label {
margin: 0;
font-size: var(--font-size-mini);

View file

@ -1,11 +1,14 @@
<template>
<div class="menu-with-submenu flex-between">
<div
class="menu-with-submenu flex-between"
:class="{ disabled: !subMenuAvailable }"
>
<div class="menu-left">
<fluent-icon :icon="option.icon" size="14" class="menu-icon" />
<p class="menu-label">{{ option.label }}</p>
</div>
<fluent-icon icon="chevron-right" size="12" />
<div class="submenu">
<div v-if="subMenuAvailable" class="submenu">
<slot />
</div>
</div>
@ -18,6 +21,10 @@ export default {
type: Object,
default: () => {},
},
subMenuAvailable: {
type: Boolean,
default: true,
},
},
};
</script>
@ -55,6 +62,11 @@ export default {
left: 100%;
top: 0;
display: none;
min-height: min-content;
max-height: var(--space-giga);
overflow-y: auto;
// Need this because Firefox adds a horizontal scrollbar, if a text is truncated inside.
overflow-x: hidden;
}
&:hover {
@ -73,5 +85,10 @@ export default {
clip-path: polygon(100% 0, 0% 0%, 100% 100%);
}
}
&.disabled {
opacity: 50%;
cursor: not-allowed;
}
}
</style>

View file

@ -57,7 +57,7 @@
</li>
</ul>
<div v-else class="agent-confirmation-container">
<p>
<p v-if="selectedAgent.id">
{{
$t('BULK_ACTION.ASSIGN_CONFIRMATION_LABEL', {
conversationCount,
@ -67,6 +67,15 @@
<strong>
{{ selectedAgent.name }}
</strong>
<span>?</span>
</p>
<p v-else>
{{
$t('BULK_ACTION.UNASSIGN_CONFIRMATION_LABEL', {
conversationCount,
conversationLabel,
})
}}
</p>
<div class="agent-confirmation-actions">
<woot-button
@ -82,7 +91,7 @@
:is-loading="uiFlags.isUpdating"
@click="submit"
>
{{ $t('BULK_ACTION.ASSIGN_LABEL') }}
{{ $t('BULK_ACTION.YES') }}
</woot-button>
</div>
</div>
@ -131,7 +140,17 @@ export default {
agent.name.toLowerCase().includes(this.query.toLowerCase())
);
}
return this.assignableAgents;
return [
{
confirmed: true,
name: 'None',
id: null,
role: 'agent',
account_id: 0,
email: 'None',
},
...this.assignableAgents,
];
},
assignableAgents() {
return this.$store.getters['inboxAssignableAgents/getAssignableAgents'](

View file

@ -181,7 +181,7 @@ export default {
color: var(--y-700);
font-size: var(--font-size-mini);
margin-top: var(--space-small);
padding: var(--space-half) var(--space-one);
padding: var(--space-smaller) var(--space-small);
}
.popover-animation-enter-active,

View file

@ -0,0 +1,13 @@
export const FEATURE_FLAGS = {
AGENT_BOTS: 'agent_bots',
AGENT_MANAGEMENT: 'agent_management',
AUTOMATIONS: 'automations',
CANNED_RESPONSES: 'canned_responses',
CUSTOM_ATTRIBUTES: 'custom_attributes',
INBOX_MANAGEMENT: 'inbox_management',
INTEGRATIONS: 'integrations',
LABELS: 'labels',
MACROS: 'macros',
TEAM_MANAGEMENT: 'team_management',
VOICE_RECORDER: 'voice_recorder',
};

View file

@ -25,6 +25,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'notification.created': this.onNotificationCreated,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
};
}
@ -67,8 +68,7 @@ class ActionCableConnector extends BaseActionCableConnector {
};
onConversationRead = data => {
const { contact_last_seen_at: lastSeen } = data;
this.app.$store.dispatch('updateConversationRead', lastSeen);
this.app.$store.dispatch('updateConversation', data);
};
onLogout = () => AuthAPI.logout();
@ -85,6 +85,11 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;

View file

@ -0,0 +1,94 @@
export const teams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
export const labels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
export const files = [
{
id: 76,
macro_id: 77,
file_type: 'image/jpeg',
account_id: 1,
file_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBYUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--aa41b5a779a83c1d86b28475a5cf0bd17f41f0ff/fayaz_cropped.jpeg',
blob_id: 88,
filename: 'fayaz_cropped.jpeg',
},
{
id: 82,
macro_id: 77,
file_type: 'image/png',
account_id: 1,
file_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBZdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--260fda80b77409ffaaac10b96681fba447600545/screenshot.png',
blob_id: 94,
filename: 'screenshot.png',
},
];

View file

@ -0,0 +1,67 @@
import {
emptyMacro,
resolveActionName,
resolveLabels,
resolveTeamIds,
getFileName,
} from '../../routes/dashboard/settings/macros/macroHelper';
import { MACRO_ACTION_TYPES } from '../../routes/dashboard/settings/macros/constants';
import { teams, labels, files } from './macrosFixtures';
describe('#emptyMacro', () => {
const defaultMacro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
it('returns the default macro', () => {
expect(emptyMacro).toEqual(defaultMacro);
});
});
describe('#resolveActionName', () => {
it('resolve action name from key and return the correct label', () => {
expect(resolveActionName(MACRO_ACTION_TYPES[0].key)).toEqual(
MACRO_ACTION_TYPES[0].label
);
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).toEqual(
MACRO_ACTION_TYPES[1].label
);
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual(
MACRO_ACTION_TYPES[0].label
);
});
});
describe('#resolveTeamIds', () => {
it('resolves team names from ids, and returns a joined string', () => {
const resolvedTeams = '⚙️ sales team, 🤷‍♂️ fayaz';
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
});
});
describe('#resolveLabels', () => {
it('resolves labels names from ids and returns a joined string', () => {
const resolvedLabels = 'sales, billing';
expect(resolveLabels(labels, ['sales', 'billing'])).toEqual(resolvedLabels);
});
});
describe('#getFileName', () => {
it('returns the correct file name from the list of files', () => {
expect(getFileName(files[0].blob_id, 'send_attachment', files)).toEqual(
files[0].filename
);
expect(getFileName(files[1].blob_id, 'send_attachment', files)).toEqual(
files[1].filename
);
expect(getFileName(files[0].blob_id, 'wrong_action', files)).toEqual('');
expect(getFileName(null, 'send_attachment', files)).toEqual('');
expect(getFileName(files[0].blob_id, 'send_attachment', [])).toEqual('');
});
});

View file

@ -0,0 +1,5 @@
{
"AGENT_BOTS": {
"HEADER": "Bots"
}
}

View file

@ -2,9 +2,11 @@
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} المحادثات المحددة",
"AGENT_SELECT_LABEL": "اختر وكيل",
"ASSIGN_CONFIRMATION_LABEL": "هل أنت متأكد من أنك تريد تعيين %{conversationCount} %{conversationLabel} إلى",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign %{conversationCount} %{conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign %{conversationCount} %{conversationLabel}?",
"GO_BACK_LABEL": "العودة للخلف",
"ASSIGN_LABEL": "تكليف",
"YES": "نعم",
"ASSIGN_AGENT_TOOLTIP": "إسناد وكيل",
"ASSIGN_SUCCESFUL": "تم تعيين المحادثات بنجاح",
"ASSIGN_FAILED": "فشل في تعيين المحادثات، الرجاء المحاولة مرة أخرى",

View file

@ -306,7 +306,7 @@
"PUBLISH_ARTICLE": {
"API": {
"ERROR": "حدث خطأ أثناء نشر المقالة",
"SUCCESS": "تم نشر المقالة بنجاح"
"SUCCESS": "Article published successfully"
}
},
"ARCHIVE_ARTICLE": {

View file

@ -239,7 +239,9 @@
},
"API_CALLBACK": {
"TITLE": "عنوان Callback URL",
"SUBTITLE": "يجب عليك تكوين URL webhook في بوابة مطور فيسبوك مع عنوان URL المذكور هنا."
"SUBTITLE": "You have to configure the webhook URL and the verification token in the Facebook Developer portal with the values shown below.",
"WEBHOOK_URL": "رابط Webhook",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "إنشاء قناة واتساب",
"API": {
@ -357,7 +359,7 @@
},
"FINISH": {
"TITLE": "أصبحت قناة التواصل جاهزة الآن!",
"MESSAGE": "يمكنك الآن التواصل مع عملائك من خلال قناتك الجديدة ",
"MESSAGE": "يمكنك الآن التواصل مع عملائك من خلال قناتك الجديدة",
"BUTTON_TEXT": "خذني إلى هناك",
"MORE_SETTINGS": "المزيد من الإعدادات",
"WEBSITE_SUCCESS": "لقد انتهيت بنجاح من إنشاء قناة دردشة مباشرة لموقعك. انسخ الرمز الموضح أدناه وقم بإضافته إلى موقع الويب الخاص بك. في المرة القادمة التي يستخدم فيها العميل الدردشة المباشرة، ستظهر المحادثة تلقائياً على صندوق الوارد الخاص بك."

View file

@ -0,0 +1,5 @@
{
"MACROS": {
"HEADER": "Macros"
}
}

View file

@ -179,6 +179,7 @@
"CONTACTS": "جهات الاتصال",
"HOME": "الرئيسية",
"AGENTS": "موظف الدعم",
"AGENT_BOTS": "Bots",
"INBOXES": "قنوات التواصل",
"NOTIFICATIONS": "الإشعارات",
"CANNED_RESPONSES": "الردود السريعة",
@ -189,6 +190,7 @@
"LABELS": "الوسوم",
"CUSTOM_ATTRIBUTES": "سمات مخصصة",
"AUTOMATION": "الأتمتة",
"MACROS": "Macros",
"TEAMS": "الفرق",
"BILLING": "الفواتير",
"CUSTOM_VIEWS_FOLDER": "المجلدات",

View file

@ -0,0 +1,5 @@
{
"AGENT_BOTS": {
"HEADER": "Bots"
}
}

View file

@ -2,9 +2,11 @@
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Select Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign %{conversationCount} %{conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign %{conversationCount} %{conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assign",
"YES": "Yes",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",

View file

@ -306,7 +306,7 @@
"PUBLISH_ARTICLE": {
"API": {
"ERROR": "Error while publishing article",
"SUCCESS": "Article publishied successfully"
"SUCCESS": "Article published successfully"
}
},
"ARCHIVE_ARTICLE": {

View file

@ -239,7 +239,9 @@
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in facebook developer portal with the URL mentioned here."
"SUBTITLE": "You have to configure the webhook URL and the verification token in the Facebook Developer portal with the values shown below.",
"WEBHOOK_URL": "Webhook URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
@ -357,7 +359,7 @@
},
"FINISH": {
"TITLE": "Your Inbox is ready!",
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting ",
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting",
"BUTTON_TEXT": "Take me there",
"MORE_SETTINGS": "More settings",
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox."

View file

@ -0,0 +1,5 @@
{
"MACROS": {
"HEADER": "Macros"
}
}

View file

@ -179,6 +179,7 @@
"CONTACTS": "Контакти",
"HOME": "Home",
"AGENTS": "Агенти",
"AGENT_BOTS": "Bots",
"INBOXES": "Inboxes",
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Готови отговори",
@ -189,6 +190,7 @@
"LABELS": "Labels",
"CUSTOM_ATTRIBUTES": "Персонализирани атрибути",
"AUTOMATION": "Автоматизация",
"MACROS": "Macros",
"TEAMS": "Teams",
"BILLING": "Billing",
"CUSTOM_VIEWS_FOLDER": "Folders",

View file

@ -0,0 +1,5 @@
{
"AGENT_BOTS": {
"HEADER": "Bots"
}
}

View file

@ -2,9 +2,11 @@
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Seleccionar Agent",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign %{conversationCount} %{conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign %{conversationCount} %{conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Assignar",
"YES": "Si",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",

View file

@ -306,7 +306,7 @@
"PUBLISH_ARTICLE": {
"API": {
"ERROR": "Error while publishing article",
"SUCCESS": "Article publishied successfully"
"SUCCESS": "Article published successfully"
}
},
"ARCHIVE_ARTICLE": {

View file

@ -239,7 +239,9 @@
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in facebook developer portal with the URL mentioned here."
"SUBTITLE": "You have to configure the webhook URL and the verification token in the Facebook Developer portal with the values shown below.",
"WEBHOOK_URL": "URL del webhook",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
@ -357,7 +359,7 @@
},
"FINISH": {
"TITLE": "La vostra safata d'entrada està a punt!",
"MESSAGE": "Ja podeu interactuar amb els vostres clients a través del vostre canal nou. Feliç suport ",
"MESSAGE": "Ja podeu interactuar amb els vostres clients a través del vostre canal nou. Feliç suport",
"BUTTON_TEXT": "Porta'm allà",
"MORE_SETTINGS": "More settings",
"WEBSITE_SUCCESS": "Heu finalitzat amb èxit la creació d'un canal web. Copieu el codi que es mostra a continuació i enganxeu-lo al lloc web. La propera vegada que un client utilitzi el xat en directe, la conversa apareixerà automàticament a la safata d'entrada."

View file

@ -0,0 +1,5 @@
{
"MACROS": {
"HEADER": "Macros"
}
}

View file

@ -179,6 +179,7 @@
"CONTACTS": "Contactes",
"HOME": "Inici",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
"INBOXES": "Safates d'entrada",
"NOTIFICATIONS": "Notificacions",
"CANNED_RESPONSES": "Respostes predeterminades",
@ -189,6 +190,7 @@
"LABELS": "Etiquetes",
"CUSTOM_ATTRIBUTES": "Atributs personalitzats",
"AUTOMATION": "Automation",
"MACROS": "Macros",
"TEAMS": "Equips",
"BILLING": "Billing",
"CUSTOM_VIEWS_FOLDER": "Folders",

View file

@ -0,0 +1,5 @@
{
"AGENT_BOTS": {
"HEADER": "Bots"
}
}

View file

@ -2,9 +2,11 @@
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} conversations selected",
"AGENT_SELECT_LABEL": "Vybrat agenta",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure you want to assign %{conversationCount} %{conversationLabel} to",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign %{conversationCount} %{conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign %{conversationCount} %{conversationLabel}?",
"GO_BACK_LABEL": "Go back",
"ASSIGN_LABEL": "Přiřadit",
"YES": "Ano",
"ASSIGN_AGENT_TOOLTIP": "Assign Agent",
"ASSIGN_SUCCESFUL": "Conversations assigned successfully",
"ASSIGN_FAILED": "Failed to assign conversations, please try again",

View file

@ -306,7 +306,7 @@
"PUBLISH_ARTICLE": {
"API": {
"ERROR": "Error while publishing article",
"SUCCESS": "Article publishied successfully"
"SUCCESS": "Article published successfully"
}
},
"ARCHIVE_ARTICLE": {

View file

@ -239,7 +239,9 @@
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in facebook developer portal with the URL mentioned here."
"SUBTITLE": "You have to configure the webhook URL and the verification token in the Facebook Developer portal with the values shown below.",
"WEBHOOK_URL": "URL webového háčku",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
@ -357,7 +359,7 @@
},
"FINISH": {
"TITLE": "Vaše doručená pošta je připravena!",
"MESSAGE": "Nyní se můžete spojit se svými zákazníky prostřednictvím nového kanálu. Šťastná podpora ",
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting",
"BUTTON_TEXT": "Vezmi mě tam",
"MORE_SETTINGS": "More settings",
"WEBSITE_SUCCESS": "Úspěšně jste dokončili vytvoření webového kanálu. Zkopírujte kód zobrazený níže a vložte jej na vaše webové stránky. Když zákazník příště použije živý chat, konverzace se automaticky objeví ve vaší doručené poště."

View file

@ -0,0 +1,5 @@
{
"MACROS": {
"HEADER": "Macros"
}
}

View file

@ -179,6 +179,7 @@
"CONTACTS": "Kontakty",
"HOME": "Domů",
"AGENTS": "Agenti",
"AGENT_BOTS": "Bots",
"INBOXES": "Schránky",
"NOTIFICATIONS": "Oznámení",
"CANNED_RESPONSES": "Konzervované odpovědi",
@ -189,6 +190,7 @@
"LABELS": "Štítky",
"CUSTOM_ATTRIBUTES": "Vlastní atributy",
"AUTOMATION": "Automation",
"MACROS": "Macros",
"TEAMS": "Týmy",
"BILLING": "Billing",
"CUSTOM_VIEWS_FOLDER": "Folders",

View file

@ -0,0 +1,5 @@
{
"AGENT_BOTS": {
"HEADER": "Bots"
}
}

View file

@ -2,9 +2,11 @@
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} samtaler valgt",
"AGENT_SELECT_LABEL": "Vælg Agent",
"ASSIGN_CONFIRMATION_LABEL": "Er du sikker på, at du vil tildele %{conversationCount} %{conversationLabel} til",
"ASSIGN_CONFIRMATION_LABEL": "Are you sure to assign %{conversationCount} %{conversationLabel} to",
"UNASSIGN_CONFIRMATION_LABEL": "Are you sure to unassign %{conversationCount} %{conversationLabel}?",
"GO_BACK_LABEL": "Gå tilbage",
"ASSIGN_LABEL": "Tildel",
"YES": "Ja",
"ASSIGN_AGENT_TOOLTIP": "Tildel Agent",
"ASSIGN_SUCCESFUL": "Samtaler tildelt",
"ASSIGN_FAILED": "Mislykkedes at tildele samtaler, prøv igen",

View file

@ -306,7 +306,7 @@
"PUBLISH_ARTICLE": {
"API": {
"ERROR": "Fejl under publicering af artikel",
"SUCCESS": "Artikel publiceret med succes"
"SUCCESS": "Article published successfully"
}
},
"ARCHIVE_ARTICLE": {

View file

@ -239,7 +239,9 @@
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "Du skal konfigurere webhook URL i facebook udvikler portal med den URL, der er nævnt her."
"SUBTITLE": "You have to configure the webhook URL and the verification token in the Facebook Developer portal with the values shown below.",
"WEBHOOK_URL": "Webhook URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook Verification Token"
},
"SUBMIT_BUTTON": "Opret WhatsApp Kanal",
"API": {
@ -357,7 +359,7 @@
},
"FINISH": {
"TITLE": "Din indbakke er klar!",
"MESSAGE": "Du kan nu engagere dig med dine kunder gennem din nye kanal. Glædelig supportering ",
"MESSAGE": "Du kan nu engagere dig med dine kunder gennem din nye kanal. Glædelig supportering",
"BUTTON_TEXT": "Tag mig med dertil",
"MORE_SETTINGS": "Flere indstillinger",
"WEBSITE_SUCCESS": "Du er færdig med at oprette en hjemmeside kanal. Kopier koden vist nedenfor og indsæt den på din hjemmeside. Næste gang en kunde bruger live chat, vil samtalen automatisk vises i din indbakke."

View file

@ -0,0 +1,5 @@
{
"MACROS": {
"HEADER": "Macros"
}
}

View file

@ -179,6 +179,7 @@
"CONTACTS": "Kontakter",
"HOME": "Hjem",
"AGENTS": "Agenter",
"AGENT_BOTS": "Bots",
"INBOXES": "Indbakker",
"NOTIFICATIONS": "Notifikationer",
"CANNED_RESPONSES": "Standardsvar Svar",
@ -189,6 +190,7 @@
"LABELS": "Etiketter",
"CUSTOM_ATTRIBUTES": "Brugerdefinerede Egenskaber",
"AUTOMATION": "Automatisering",
"MACROS": "Macros",
"TEAMS": "Teams",
"BILLING": "Fakturering",
"CUSTOM_VIEWS_FOLDER": "Mapper",

View file

@ -0,0 +1,5 @@
{
"AGENT_BOTS": {
"HEADER": "Bots"
}
}

View file

@ -2,9 +2,11 @@
"BULK_ACTION": {
"CONVERSATIONS_SELECTED": "%{conversationCount} Konversationen ausgewählt",
"AGENT_SELECT_LABEL": "Agent auswählen",
"ASSIGN_CONFIRMATION_LABEL": "Sind Sie sicher, dass Sie %{conversationCount} %{conversationLabel} zuweisen möchten",
"ASSIGN_CONFIRMATION_LABEL": "Sind Sie sicher, %{conversationCount} %{conversationLabel} zuzuweisen",
"UNASSIGN_CONFIRMATION_LABEL": "Möchten Sie die Zuweisung von %{conversationCount} %{conversationLabel} wirklich aufheben?",
"GO_BACK_LABEL": "Zurück",
"ASSIGN_LABEL": "Zuordnen",
"YES": "Ja",
"ASSIGN_AGENT_TOOLTIP": "Agent zuweisen",
"ASSIGN_SUCCESFUL": "Konversationen erfolgreich zugewiesen",
"ASSIGN_FAILED": "Konversationen konnten nicht zugewiesen werden. Bitte versuchen Sie es erneut",

View file

@ -239,7 +239,9 @@
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "Sie müssen die Webhook-URL im Facebook-Entwicklerportal mit der hier genannten URL konfigurieren."
"SUBTITLE": "Sie müssen die Webhook-URL und das Verifizierungstoken im Facebook-Entwicklerportal mit den unten gezeigten Werten konfigurieren.",
"WEBHOOK_URL": "Webhook-URL",
"WEBHOOK_VERIFICATION_TOKEN": "Webhook-Verifizierungstoken"
},
"SUBMIT_BUTTON": "WhatsApp-Kanal erstellen",
"API": {
@ -357,7 +359,7 @@
},
"FINISH": {
"TITLE": "Ihr Posteingang ist fertig!",
"MESSAGE": "Sie können jetzt über Ihren neuen Kanal mit Ihren Kunden in Kontakt treten. Viel Spaß beim Unterstützen",
"MESSAGE": "Sie können jetzt über Ihren neuen Kanal mit Ihren Kunden in Kontakt treten. Fröhliches Unterstützen",
"BUTTON_TEXT": "Bring mich dahin",
"MORE_SETTINGS": "Weitere Einstellungen",
"WEBSITE_SUCCESS": "Sie haben die Erstellung eines Website-Kanals erfolgreich abgeschlossen. Kopieren Sie den unten gezeigten Code und fügen Sie ihn in Ihre Website ein. Wenn ein Kunde das nächste Mal den Live-Chat verwendet, wird die Konversation automatisch in Ihrem Posteingang angezeigt."

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