Merge branch 'develop' into feat/new-auth-screens

This commit is contained in:
Sivin Varghese 2022-04-26 09:43:56 +05:30 committed by GitHub
commit ccb8ced4e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
290 changed files with 5676 additions and 1809 deletions

View file

@ -0,0 +1,62 @@
# #
# # This action will publish Chatwoot CE docker image.
# # This is set to run against merges to develop, master
# # and when tags are created.
# #
name: Publish Chatwoot CE docker images
on:
push:
branches:
- develop
- master
tags:
- v*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Strip enterprise code
run: |
rm -rf enterprise
rm -rf spec/enterprise
- name: Set Chatwoot edition
run: |
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
- name: set docker tag
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
- name: replace docker tag if master
if: github.ref_name == 'master'
run: |
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: docker/Dockerfile
push: true
tags: ${{ env.DOCKER_TAG }}

View file

@ -125,6 +125,9 @@ gem 'procore-sift'
gem 'email_reply_trimmer'
gem 'html2text'
# to calculate working hours
gem 'working_hours'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'

View file

@ -378,14 +378,14 @@ GEM
netrc (0.11.0)
newrelic_rpm (8.4.0)
nio4r (2.5.8)
nokogiri (1.13.3)
nokogiri (1.13.4)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.13.3-arm64-darwin)
nokogiri (1.13.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.13.3-x86_64-darwin)
nokogiri (1.13.4-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.13.3-x86_64-linux)
nokogiri (1.13.4-x86_64-linux)
racc (~> 1.4)
oauth (0.5.8)
orm_adapter (0.5.0)
@ -636,6 +636,9 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4)
PLATFORMS
@ -746,6 +749,7 @@ DEPENDENCIES
webpacker (~> 5.x)
webpush
wisper (= 2.0.0)
working_hours
RUBY VERSION
ruby 3.0.2p107

View file

@ -1,5 +1,5 @@
class Campaigns::CampaignConversationBuilder
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes]
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
@ -32,7 +32,8 @@ class Campaigns::CampaignConversationBuilder
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
campaign_id: @campaign.id,
additional_attributes: conversation_additional_attributes
additional_attributes: conversation_additional_attributes,
custom_attributes: custom_attributes || {}
}
end
end

View file

@ -9,6 +9,7 @@ class Messages::MessageBuilder
@user = user
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
@ -64,6 +65,10 @@ class Messages::MessageBuilder
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
end
def automation_rule_id
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
end
def message_sender
return if @params[:sender_type] != 'AgentBot'
@ -82,6 +87,6 @@ class Messages::MessageBuilder
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id]
}.merge(external_created_at)
}.merge(external_created_at).merge(automation_rule_id)
end
end

View file

@ -4,6 +4,7 @@ class V2::ReportBuilder
attr_reader :account, :params
DEFAULT_GROUP_BY = 'day'.freeze
AGENT_RESULTS_PER_PAGE = 10
def initialize(account, params)
@account = account
@ -79,12 +80,15 @@ class V2::ReportBuilder
end
def agent_metrics
users = @account.users
users = users.where(id: params[:user_id]) if params[:user_id].present?
users.each_with_object([]) do |user, arr|
@user = user
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
account_users.each_with_object([]) do |account_user, arr|
@user = account_user.user
arr << {
user: { id: user.id, name: user.name, thumbnail: user.avatar_url },
id: @user.id,
name: @user.name,
email: @user.email,
thumbnail: @user.avatar_url,
availability: account_user.availability_status,
metric: conversations
}
end
@ -94,7 +98,7 @@ class V2::ReportBuilder
@open_conversations = scope.conversations.open
first_response_count = scope.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
metric = {
open: @open_conversations.count,
total: @open_conversations.count,
unattended: @open_conversations.count - first_response_count
}
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)

View file

@ -17,12 +17,29 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule
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 show; end
def update
@automation_rule.update(automation_rules_permit)
process_attachments
@automation_rule
ActiveRecord::Base.transaction do
@automation_rule.update!(automation_rules_permit)
@automation_rule.actions = params[:actions] if params[:actions]
@automation_rule.save!
process_attachments
rescue StandardError => e
Rails.logger.error e
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
end
end
def destroy
@ -37,16 +54,19 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
@automation_rule = new_rule
end
private
def process_attachments
return if params[:attachments].blank?
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
return if actions.blank?
params[:attachments].each do |uploaded_attachment|
@automation_rule.files.attach(uploaded_attachment)
actions.each do |action|
blob_id = action['action_params']
blob = ActiveStorage::Blob.find_by(id: blob_id)
@automation_rule.files.attach(blob)
end
end
private
def automation_rules_permit
params.permit(
:name, :description, :event_name, :account_id, :active,

View file

@ -4,6 +4,6 @@ class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseControll
private
def portal
@portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id])
@portal ||= Current.account.kbase_portals.find_by(slug: params[:portal_id])
end
end

View file

@ -1,10 +1,12 @@
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseController
before_action :fetch_portal, except: [:index, :create]
def index
@portals = Current.account.kbase_portals
end
def show; end
def create
@portal = Current.account.kbase_portals.create!(portal_params)
end
@ -21,7 +23,11 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba
private
def fetch_portal
@portal = current_account.kbase_portals.find(params[:id])
@portal = Current.account.kbase_portals.find_by(slug: permitted_params[:id])
end
def permitted_params
params.permit(:id)
end
def portal_params

View file

@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
private
def webhook_params
params.require(:webhook).permit(:inbox_id, :url)
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
end
def fetch_webhook

View file

@ -39,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController
browser: browser_params,
referer: permitted_params[:message][:referer_url],
initiated_at: timestamp_params
}
},
custom_attributes: permitted_params[:custom_attributes].presence || {}
}
end
@ -52,16 +53,33 @@ class Api::V1::Widget::BaseController < ApplicationController
mergee_contact: @contact
).perform
else
@contact.update!(email: email, name: contact_name)
@contact.update!(email: email)
end
end
def update_contact_phone_number(phone_number)
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
if contact_with_phone_number
@contact = ::ContactMergeAction.new(
account: @current_account,
base_contact: contact_with_phone_number,
mergee_contact: @contact
).perform
else
@contact.update!(phone_number: phone_number)
end
end
def contact_email
permitted_params[:contact][:email].downcase
permitted_params.dig(:contact, :email)&.downcase
end
def contact_name
params[:contact][:name] || contact_email.split('@')[0]
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
end
def contact_phone_number
permitted_params.dig(:contact, :phone_number)
end
def browser_params

View file

@ -7,12 +7,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def create
ActiveRecord::Base.transaction do
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
process_update_contact
@conversation = create_conversation
conversation.messages.create(message_params)
end
end
def process_update_contact
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
@contact.update!(name: contact_name) if contact_name.present?
end
def update_last_seen
head :ok && return if conversation.nil?
@ -63,6 +69,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
message: [:content, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
end

View file

@ -47,42 +47,43 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
def current_summary_params
def common_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:current][:since],
until: range[:current][:until],
group_by: params[:group_by]
group_by: params[:group_by],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:previous][:since],
until: range[:previous][:until],
group_by: params[:group_by]
}
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until]
})
end
def report_params
{
metric: params[:metric],
type: params[:type].to_sym,
since: params[:since],
until: params[:until],
id: params[:id],
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
}
common_params.merge({
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone_offset: params[:timezone_offset]
})
end
def conversation_params
{
type: params[:type].to_sym,
user_id: params[:user_id]
user_id: params[:user_id],
page: params[:page].presence || 1
}
end

View file

@ -0,0 +1,15 @@
class EmailChannelFinder
def initialize(email_object)
@email_object = email_object
end
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
break if channel.present?
end
channel
end
end

View file

@ -14,7 +14,7 @@ module Api::V1::InboxesHelper
Mail.defaults do
retriever_method :imap, { address: channel_data[:imap_address],
port: channel_data[:imap_port],
user_name: channel_data[:imap_email],
user_name: channel_data[:imap_login],
password: channel_data[:imap_password],
enable_ssl: channel_data[:imap_enable_ssl] }
end
@ -29,8 +29,12 @@ module Api::V1::InboxesHelper
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
set_smtp_encryption(channel_data, smtp)
check_smtp_connection(channel_data, smtp)
end
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login)
def check_smtp_connection(channel_data, smtp)
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
channel_data[:smtp_authentication]&.to_sym || :login)
smtp.finish unless smtp&.nil?
end

View file

@ -33,17 +33,23 @@ module ReportHelper
end
def avg_first_response_time
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response'))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end
def avg_resolution_time
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved'))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end
def avg_resolution_time_summary
avg_rt = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
.average(:value)
reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_rt.blank?
@ -51,9 +57,9 @@ module ReportHelper
end
def avg_first_response_time_summary
avg_frt = scope.reporting_events
.where(name: 'first_response', created_at: range)
.average(:value)
reporting_events = scope.reporting_events
.where(name: 'first_response', created_at: range)
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_frt.blank?

View file

@ -0,0 +1,50 @@
module ReportingEventHelper
def business_hours(inbox, from, to)
return 0 unless inbox.working_hours_enabled?
inbox_working_hours = configure_working_hours(inbox.working_hours)
return 0 if inbox_working_hours.blank?
# Configure working hours
WorkingHours::Config.working_hours = inbox_working_hours
# Configure timezone
WorkingHours::Config.time_zone = inbox.timezone
# Use inbox timezone to change from & to values.
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
end
private
def configure_working_hours(working_hours)
working_hours.each_with_object({}) do |working_hour, object|
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
end
end
def day(day_of_week)
week_days = {
0 => :sun,
1 => :mon,
2 => :tue,
3 => :wed,
4 => :thu,
5 => :fri,
6 => :sat
}
week_days[day_of_week]
end
def working_hour_range(working_hour)
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
end
def format_time(hour, minute)
hour = hour < 10 ? "0#{hour}" : hour
minute = minute < 10 ? "0#{minute}" : minute
"#{hour}:#{minute}"
end
end

View file

@ -1,5 +1,5 @@
<template>
<div id="app" class="app-wrapper app-root">
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<transition name="fade" mode="out-in">
<router-view></router-view>
@ -11,21 +11,28 @@
<woot-snackbar-box />
<network-notification />
</div>
<loading-state v-else />
</template>
<script>
import { accountIdFromPathname } from './helper/URLHelper';
import { mapGetters } from 'vuex';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification';
import UpdateBanner from './components/app/UpdateBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer';
import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
export default {
name: 'App',
components: {
AddAccountModal,
LoadingState,
NetworkNotification,
UpdateBanner,
WootSnackbarBox,
@ -43,13 +50,12 @@ export default {
getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
currentAccountId: 'getCurrentAccountId',
}),
hasAccounts() {
return (
this.currentUser &&
this.currentUser.accounts &&
this.currentUser.accounts.length !== 0
);
const { accounts = [] } = this.currentUser || {};
return accounts.length > 0;
},
},
@ -58,32 +64,37 @@ export default {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
},
currentAccountId() {
if (this.currentAccountId) {
this.initializeAccount();
}
},
},
mounted() {
this.$store.dispatch('setUser');
this.setLocale(window.chatwootConfig.selectedLocale);
this.initializeAccount();
},
methods: {
setLocale(locale) {
this.$root.$i18n.locale = locale;
},
async initializeAccount() {
const { pathname } = window.location;
const accountId = accountIdFromPathname(pathname);
if (accountId) {
await this.$store.dispatch('accounts/get');
const {
locale,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(accountId);
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
}
await this.$store.dispatch('accounts/get');
const {
locale,
latest_chatwoot_version: latestChatwootVersion,
} = this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
},
},
};

View file

@ -1,6 +1,4 @@
/* eslint no-console: 0 */
/* global axios */
/* eslint no-undef: "error" */
import Cookies from 'js-cookie';
import endPoints from './endPoints';
@ -61,41 +59,15 @@ export default {
});
return fetchPromise;
},
isLoggedIn() {
const hasAuthCookie = !!Cookies.getJSON('auth_data');
const hasUserCookie = !!Cookies.getJSON('user');
return hasAuthCookie && hasUserCookie;
hasAuthCookie() {
return !!Cookies.getJSON('cw_d_session_info');
},
isAdmin() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user').role === 'administrator';
}
return false;
},
getAuthData() {
if (this.isLoggedIn()) {
return Cookies.getJSON('auth_data');
if (this.hasAuthCookie()) {
return Cookies.getJSON('cw_d_session_info');
}
return false;
},
getPubSubToken() {
if (this.isLoggedIn()) {
const user = Cookies.getJSON('user') || {};
const { pubsub_token: pubsubToken } = user;
return pubsubToken;
}
return null;
},
getCurrentUser() {
if (this.isLoggedIn()) {
return Cookies.getJSON('user');
}
return null;
},
verifyPasswordToken({ confirmationToken }) {
return new Promise((resolve, reject) => {
axios

View file

@ -9,6 +9,14 @@ class AutomationsAPI extends ApiClient {
clone(automationId) {
return axios.post(`${this.url}/${automationId}/clone`);
}
attachment(file) {
return axios.post(`${this.url}/attach_file`, file, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
}
export default new AutomationsAPI();

View file

@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' });
}
getReports(metric, since, until, type = 'account', id, group_by) {
getReports(
metric,
since,
until,
type = 'account',
id,
group_by,
business_hours
) {
return axios.get(`${this.url}`, {
params: {
metric,
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
type,
id,
group_by,
business_hours,
timezone_offset: getTimeOffset(),
},
});
}
getSummary(since, until, type = 'account', id, group_by) {
getSummary(since, until, type = 'account', id, group_by, business_hours) {
return axios.get(`${this.url}/summary`, {
params: {
since,
@ -30,6 +39,16 @@ class ReportsAPI extends ApiClient {
type,
id,
group_by,
business_hours,
},
});
}
getConversationMetric(type = 'account', page = 1) {
return axios.get(`${this.url}/conversations`, {
params: {
type,
page,
},
});
}

View file

@ -97,5 +97,18 @@ describe('#Reports API', () => {
}
);
});
it('#getConversationMetric', () => {
reportsAPI.getConversationMetric('account');
expect(context.axiosMock.get).toHaveBeenCalledWith(
'/api/v2/reports/conversations',
{
params: {
type: 'account',
page: 1,
},
}
);
});
});
});

View file

@ -2,6 +2,10 @@
margin-right: var(--space-small);
}
.margin-bottom-small {
margin-bottom: var(--space-small);
}
.margin-right-smaller {
margin-right: var(--space-smaller);
}
@ -51,10 +55,6 @@
background-color: var(--white);
}
.text-y-800 {
color: var(--y-800);
}
.text-ellipsis {
overflow: hidden;
text-overflow: ellipsis;

View file

@ -78,5 +78,10 @@
font-size: $font-size-default;
color: $color-gray;
}
.business-hours {
margin: $space-normal;
text-align: center;
}
}
}

View file

@ -25,3 +25,21 @@
align-items: center;
display: flex;
}
.business-hours {
align-items: center;
display: flex;
justify-content: end;
margin-bottom: var(--space-normal);
margin-left: auto;
padding-right: var(--space-normal);
}
.business-hours-text {
font-size: var(--font-size-small);
}
.switch {
margin-bottom: var(--space-zero);
margin-left: var(--space-small);
}

View file

@ -7,6 +7,10 @@
<p class="sub-head">
{{ subTitle }}
</p>
<p v-if="note">
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
{{ note }}
</p>
</div>
<div class="medium-6 small-12">
<slot></slot>
@ -25,6 +29,10 @@ export default {
type: String,
required: true,
},
note: {
type: String,
default: '',
},
},
};
</script>
@ -46,5 +54,9 @@ export default {
.title--section {
padding-right: var(--space-large);
}
.note {
font-weight: var(--font-weight-bold);
}
}
</style>

View file

@ -53,7 +53,7 @@ export default {
computed: {
...mapGetters({
getCurrentUserAvailability: 'getCurrentUserAvailability',
getCurrentAccountId: 'getCurrentAccountId',
currentAccountId: 'getCurrentAccountId',
}),
availabilityDisplayLabel() {
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
@ -63,9 +63,6 @@ export default {
availabilityIndex
];
},
currentAccountId() {
return this.getCurrentAccountId;
},
currentUserAvailability() {
return this.getCurrentUserAvailability;
},

View file

@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
const reports = accountId => ({
parentNav: 'reports',
routes: [
'settings_account_reports',
'account_overview_reports',
'conversation_reports',
'csat_reports',
'agent_reports',
'label_reports',
@ -16,7 +17,14 @@ const reports = accountId => ({
label: 'REPORTS_OVERVIEW',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/overview`),
toStateName: 'settings_account_reports',
toStateName: 'account_overview_reports',
},
{
icon: 'chat',
label: 'REPORTS_CONVERSATION',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
toStateName: 'conversation_reports',
},
{
icon: 'emoji',

View file

@ -85,14 +85,20 @@ export default {
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
toStateName: 'settings_inbox_new',
newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({
id: inbox.id,
label: inbox.name,
truncateLabel: true,
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
})),
children: this.inboxes
.map(inbox => ({
id: inbox.id,
label: inbox.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/inbox/${inbox.id}`
),
type: inbox.channel_type,
phoneNumber: inbox.phone_number,
}))
.sort((a, b) =>
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
),
};
},
labelSection() {

View file

@ -112,10 +112,10 @@ export default {
}
&.warning {
background: var(--y-800);
color: var(--s-600);
background: var(--y-600);
color: var(--y-500);
a {
color: var(--s-600);
color: var(--y-500);
}
}

View file

@ -157,7 +157,7 @@ export default {
&.warning {
background: var(--y-100);
color: var(--y-900);
border: 1px solid var(--y-300);
border: 1px solid var(--y-200);
a {
color: var(--y-900);
}

View file

@ -1,54 +1,70 @@
<template>
<label class="switch" :class="classObject">
<input
:id="id"
v-model="value"
class="switch-input"
:name="name"
:disabled="disabled"
type="checkbox"
/>
<div class="switch-paddle" :for="name">
<span class="show-for-sr">on off</span>
</div>
</label>
<button
type="button"
class="toggle-button"
:class="{ active: value }"
role="switch"
:aria-checked="value.toString()"
@click="onClick"
>
<span aria-hidden="true" :class="{ active: value }"></span>
</button>
</template>
<script>
export default {
props: {
disabled: Boolean,
type: { type: String, default: '' },
size: { type: String, default: '' },
checked: Boolean,
name: { type: String, default: '' },
id: { type: String, default: '' },
value: { type: Boolean, default: false },
},
data() {
return {
value: null,
};
},
computed: {
classObject() {
const { type, size, value } = this;
return {
[`is-${type}`]: type,
[`${size}`]: size,
checked: value,
};
methods: {
onClick(event) {
if (event.pointerId === -1) {
event.preventDefault();
} else {
this.$emit('input', !this.value);
}
},
},
watch: {
value(val) {
this.$emit('input', val);
},
},
beforeMount() {
this.value = this.checked;
},
mounted() {
this.$emit('input', (this.value = !!this.checked));
},
};
</script>
<style lang="scss" scoped>
.toggle-button {
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
background-color: var(--s-200);
border-radius: var(--border-radius-large);
border: 2px solid transparent;
cursor: pointer;
display: flex;
flex-shrink: 0;
height: 19px;
position: relative;
transition-duration: 200ms;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: 34px;
&.active {
background-color: var(--w-500);
}
span {
--space-one-point-five: 1.5rem;
background-color: var(--white);
border-radius: 100%;
box-shadow: var(--toggle-button-box-shadow);
display: inline-block;
height: var(--space-one-point-five);
transform: translate(0, 0);
transition-duration: 200ms;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
width: var(--space-one-point-five);
&.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
}
}
</style>

View file

@ -7,7 +7,7 @@
<select
v-model="action_name"
class="action__question"
:class="{ 'full-width': !inputType }"
:class="{ 'full-width': !showActionInput }"
@change="resetAction()"
>
<option
@ -52,6 +52,11 @@
class="answer--text-input"
placeholder="Enter url"
/>
<automation-action-file-input
v-if="inputType === 'attachment'"
v-model="action_params"
:initial-file-name="initialFileName"
/>
</div>
</div>
<woot-button
@ -61,6 +66,18 @@
@click="removeAction"
/>
</div>
<automation-action-team-message-input
v-if="inputType === 'team_message'"
v-model="action_params"
:teams="dropdownValues"
/>
<textarea
v-if="inputType === 'textarea'"
v-model="action_params"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
class="action-message"
></textarea>
<p
v-if="v.action_params.$dirty && v.action_params.$error"
class="filter-error"
@ -71,7 +88,13 @@
</template>
<script>
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
import AutomationActionFileInput from './AutomationFileInput.vue';
export default {
components: {
AutomationActionTeamMessageInput,
AutomationActionFileInput,
},
props: {
value: {
type: Object,
@ -93,6 +116,10 @@ export default {
type: Boolean,
default: true,
},
initialFileName: {
type: String,
default: '',
},
},
computed: {
action_name: {
@ -171,6 +198,7 @@ export default {
.filter__answer--wrap {
margin-right: var(--space-smaller);
flex-grow: 1;
max-width: 50%;
input {
margin-bottom: 0;
@ -211,4 +239,7 @@ export default {
.multiselect {
margin-bottom: var(--space-zero);
}
.action-message {
margin: var(--space-small) 0 0;
}
</style>

View file

@ -0,0 +1,62 @@
<template>
<div>
<div class="multiselect-wrap--small">
<multiselect
v-model="selectedTeams"
track-by="id"
label="name"
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
:multiple="true"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
deselect-label=""
:max-height="160"
:options="teams"
:allow-empty="false"
@input="updateValue"
/>
<textarea
v-model="message"
rows="4"
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
@input="updateValue"
></textarea>
</div>
</div>
</template>
<script>
export default {
// The value types are dynamic, hence prop validation removed to work with our action schema
// eslint-disable-next-line vue/require-prop-types
props: ['teams', 'value'],
data() {
return {
selectedTeams: [],
message: '',
};
},
mounted() {
const { team_ids: teamIds } = this.value;
this.selectedTeams = teamIds;
this.message = this.value.message;
},
methods: {
updateValue() {
this.$emit('input', {
team_ids: this.selectedTeams.map(team => team.id),
message: this.message,
});
},
},
};
</script>
<style scoped>
.multiselect {
margin: var(--space-smaller) var(--space-zero);
}
textarea {
margin-bottom: var(--space-zero);
}
</style>

View file

@ -0,0 +1,117 @@
<template>
<label class="input-wrapper" :class="uploadState">
<input
v-if="uploadState !== 'processing'"
type="file"
name="attachment"
:class="uploadState === 'processing' ? 'disabled' : ''"
@change="onChangeFile"
/>
<spinner v-if="uploadState === 'processing'" />
<fluent-icon v-if="uploadState === 'idle'" icon="file-upload" />
<fluent-icon
v-if="uploadState === 'uploaded'"
icon="checkmark-circle"
type="outline"
class="success-icon"
/>
<fluent-icon
v-if="uploadState === 'failed'"
icon="dismiss-circle"
type="outline"
class="error-icon"
/>
<p class="file-button">{{ label }}</p>
</label>
</template>
<script>
import Spinner from 'shared/components/Spinner';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Spinner,
},
mixins: [alertMixin],
props: {
value: {
type: Array,
default: () => [],
},
initialFileName: {
type: String,
default: '',
},
},
data() {
return {
uploadState: 'idle',
label: this.$t('AUTOMATION.ATTACHMENT.LABEL_IDLE'),
};
},
mounted() {
if (this.initialFileName) {
this.label = this.initialFileName;
this.uploadState = 'uploaded';
}
},
methods: {
async onChangeFile(event) {
this.uploadState = 'processing';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
try {
const file = event.target.files[0];
const formData = new FormData();
formData.append('attachment', file, file.name);
const id = await this.$store.dispatch(
'automations/uploadAttachment',
formData
);
this.$emit('input', [id]);
this.uploadState = 'uploaded';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
} catch (error) {
this.uploadState = 'failed';
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');
this.showAlert(this.$t('AUTOMATION.ATTACHMENT.UPLOAD_ERROR'));
}
},
},
};
</script>
<style scoped>
input[type='file'] {
display: none;
}
.input-wrapper {
display: flex;
height: 39px;
background-color: var(--white);
border-radius: var(--border-radius-small);
border: 1px dashed var(--w-100);
padding: var(--space-smaller) var(--space-small);
align-items: center;
font-size: var(--font-size-mini);
cursor: pointer;
}
.success-icon {
margin-right: var(--space-small);
color: var(--g-500);
}
.error-icon {
margin-right: var(--space-small);
color: var(--r-500);
}
.processing {
cursor: not-allowed;
opacity: 0.9;
}
.file-button {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
</style>

View file

@ -0,0 +1,50 @@
<template>
<span>
{{ textToBeDisplayed }}
<button class="show-more--button" @click="toggleShowMore">
{{ buttonLabel }}
</button>
</span>
</template>
<script>
export default {
props: {
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
},
data() {
return {
showMore: false,
};
},
computed: {
textToBeDisplayed() {
if (this.showMore) {
return this.text;
}
return this.text.slice(0, this.limit) + '...';
},
buttonLabel() {
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
},
},
methods: {
toggleShowMore() {
this.showMore = !this.showMore;
},
},
};
</script>
<style scoped>
.show-more--button {
color: var(--w-500);
}
</style>

View file

@ -209,7 +209,7 @@ export default {
}
.user-online-status--busy {
background: var(--y-700);
background: var(--y-500);
}
.user-online-status--offline {

View file

@ -23,7 +23,7 @@ export default {
background: var(--s-500);
}
&__busy {
background: var(--y-400);
background: var(--y-500);
}
}
</style>

View file

@ -14,8 +14,8 @@
<fluent-icon
v-if="!isHMACVerified"
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
class="text-y-800"
size="14"
class="hmac-warning__icon"
icon="warning"
/>
</h3>
@ -181,7 +181,11 @@ export default {
.snoozed--display-text {
font-weight: var(--font-weight-medium);
color: var(--y-900);
color: var(--y-600);
}
}
.hmac-warning__icon {
color: var(--y-600);
}
</style>

View file

@ -60,6 +60,7 @@
:readable-time="readableTime"
:source-id="data.source_id"
:inbox-id="data.inbox_id"
:message-read="showReadTicks"
/>
</div>
<spinner v-if="isPending" size="tiny" />
@ -153,6 +154,14 @@ export default {
type: Boolean,
default: false,
},
hasUserReadMessage: {
type: Boolean,
default: false,
},
isWebWidgetInbox: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -268,6 +277,14 @@ export default {
isOutgoing() {
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
},
showReadTicks() {
return (
(this.isOutgoing || this.isTemplate) &&
this.hasUserReadMessage &&
this.isWebWidgetInbox &&
!this.data.private
);
},
isTemplate() {
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
},

View file

@ -48,6 +48,10 @@
:data="message"
:is-a-tweet="isATweet"
:has-instagram-story="hasInstagramStory"
:has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt)
"
:is-web-widget-inbox="isAWebWidgetInbox"
/>
<li v-show="getUnreadCount != 0" class="unread--toast">
<span class="text-uppercase">
@ -66,6 +70,10 @@
:data="message"
:is-a-tweet="isATweet"
:has-instagram-story="hasInstagramStory"
:has-user-read-message="
hasUserReadMessage(message.created_at, getLastSeenAt)
"
:is-web-widget-inbox="isAWebWidgetInbox"
/>
</ul>
<div
@ -83,7 +91,6 @@
</div>
</div>
<reply-box
v-on-clickaway="closePopoutReplyBox"
:conversation-id="currentChat.id"
:is-a-tweet="isATweet"
:selected-tweet="selectedTweet"
@ -109,7 +116,6 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
import { isEscape } from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { mixin as clickaway } from 'vue-clickaway';
export default {
components: {
@ -117,7 +123,7 @@ export default {
ReplyBox,
Banner,
},
mixins: [conversationMixin, inboxMixin, eventListenerMixins, clickaway],
mixins: [conversationMixin, inboxMixin, eventListenerMixins],
props: {
isContactPanelOpen: {
type: Boolean,
@ -143,6 +149,7 @@ export default {
listLoadingStatus: 'getAllMessagesLoaded',
getUnreadCount: 'getUnreadCount',
loadingChatList: 'getChatListLoadingStatus',
conversationLastSeen: 'getConversationLastSeen',
}),
inboxId() {
return this.currentChat.inbox_id;
@ -243,6 +250,11 @@ export default {
}
return 'arrow-chevron-left';
},
getLastSeenAt() {
if (this.conversationLastSeen) return this.conversationLastSeen;
const { contact_last_seen_at: contactLastSeenAt } = this.currentChat;
return contactLastSeenAt;
},
},
watch: {

View file

@ -80,8 +80,8 @@
>
<p
v-if="isSignatureAvailable"
v-dompurify-html="formatMessage(messageSignature)"
class="message-signature"
v-html="formatMessage(messageSignature)"
/>
<p v-else class="message-signature">
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}

View file

@ -8,6 +8,13 @@
size="16"
/>
</span>
<fluent-icon
v-if="messageRead"
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
icon="checkmark-double"
class="action--icon read-tick"
size="12"
/>
<fluent-icon
v-if="isEmail"
v-tooltip.top-start="$t('CHAT_LIST.RECEIVED_VIA_EMAIL')"
@ -120,6 +127,10 @@ export default {
type: [String, Number],
default: 0,
},
messageRead: {
type: Boolean,
default: false,
},
},
computed: {
inbox() {
@ -173,6 +184,10 @@ export default {
}
.action--icon {
&.read-tick {
color: var(--v-100);
margin-top: calc(var(--space-micro) + var(--space-micro) / 2);
}
color: var(--white);
}

View file

@ -6,7 +6,7 @@
'hide--quoted': !showQuotedContent,
}"
>
<div class="text-content" v-html="message"></div>
<div v-dompurify-html="message" class="text-content"></div>
<button
v-if="displayQuotedButton"
class="quoted-text--button"

View file

@ -1,4 +1,3 @@
/* eslint no-console: 0 */
import Auth from '../api/auth';
const parseErrorCode = error => Promise.reject(error);
@ -7,7 +6,7 @@ export default axios => {
const { apiHost = '' } = window.chatwootConfig || {};
const wootApi = axios.create({ baseURL: `${apiHost}/` });
// Add Auth Headers to requests if logged in
if (Auth.isLoggedIn()) {
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,

View file

@ -14,6 +14,9 @@ export const getLoginRedirectURL = (ssoAccountId, user) => {
if (ssoAccount) {
return frontendURL(`accounts/${ssoAccountId}/dashboard`);
}
if (accounts.length) {
return frontendURL(`accounts/${accounts[0].id}/dashboard`);
}
return DEFAULT_REDIRECT_URL;
};
@ -41,15 +44,6 @@ export const conversationUrl = ({
return url;
};
export const accountIdFromPathname = pathname => {
const isInsideAccountScopedURLs = pathname.includes('/app/accounts');
const urlParam = pathname.split('/')[3];
// eslint-disable-next-line no-restricted-globals
const isScoped = isInsideAccountScopedURLs && !isNaN(urlParam);
const accountId = isScoped ? Number(urlParam) : '';
return accountId;
};
export const isValidURL = value => {
/* eslint-disable no-useless-escape */
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;

View file

@ -23,6 +23,8 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
};
}
@ -64,6 +66,11 @@ class ActionCableConnector extends BaseActionCableConnector {
this.fetchConversationStats();
};
onConversationRead = data => {
const { contact_last_seen_at: lastSeen } = data;
this.app.$store.dispatch('updateConversationRead', lastSeen);
};
onLogout = () => AuthAPI.logout();
onMessageCreated = data => {
@ -122,6 +129,7 @@ class ActionCableConnector extends BaseActionCableConnector {
fetchConversationStats = () => {
bus.$emit('fetch_conversation_stats');
bus.$emit('fetch_overview_reports');
};
onContactDelete = data => {
@ -139,17 +147,14 @@ class ActionCableConnector extends BaseActionCableConnector {
onNotificationCreated = data => {
this.app.$store.dispatch('notifications/addNotification', data);
};
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
};
}
export default {
init() {
if (AuthAPI.isLoggedIn()) {
const actionCable = new ActionCableConnector(
window.WOOT,
AuthAPI.getPubSubToken()
);
return actionCable;
}
return null;
init(pubsubToken) {
return new ActionCableConnector(window.WOOT, pubsubToken);
},
};

View file

@ -1,7 +1,15 @@
const allElementsString = arr => {
return arr.every(elem => typeof elem === 'string');
};
const allElementsNumbers = arr => {
return arr.every(elem => typeof elem === 'number');
};
const formatArray = params => {
if (params.length <= 0) {
params = [];
} else if (params.every(elem => typeof elem === 'string')) {
} else if (allElementsString(params) || allElementsNumbers(params)) {
params = [...params];
} else {
params = params.map(val => val.id);

View file

@ -0,0 +1,100 @@
import i18n from 'widget/i18n/index';
const defaultTranslations = Object.fromEntries(
Object.entries(i18n).filter(([key]) => key.includes('en'))
).en;
export const standardFieldKeys = {
emailAddress: {
key: 'EMAIL_ADDRESS',
label: 'Email Id',
placeholder: 'Please enter your email address',
},
fullName: {
key: 'FULL_NAME',
label: 'Full Name',
placeholder: 'Please enter your full name',
},
phoneNumber: {
key: 'PHONE_NUMBER',
label: 'Phone Number',
placeholder: 'Please enter your phone number',
},
};
export const getLabel = ({ key, label }) => {
return defaultTranslations.PRE_CHAT_FORM.FIELDS[key]
? defaultTranslations.PRE_CHAT_FORM.FIELDS[key].LABEL
: label;
};
export const getPlaceHolder = ({ key, placeholder }) => {
return defaultTranslations.PRE_CHAT_FORM.FIELDS[key]
? defaultTranslations.PRE_CHAT_FORM.FIELDS[key].PLACEHOLDER
: placeholder;
};
export const getCustomFields = ({ standardFields, customAttributes }) => {
let customFields = [];
const { pre_chat_fields: preChatFields } = standardFields;
customAttributes.forEach(attribute => {
const itemExist = preChatFields.find(
item => item.name === attribute.attribute_key
);
if (!itemExist) {
customFields.push({
label: attribute.attribute_display_name,
placeholder: attribute.attribute_display_name,
name: attribute.attribute_key,
type: attribute.attribute_display_type,
values: attribute.attribute_values,
field_type: attribute.attribute_model,
required: false,
enabled: false,
});
}
});
return customFields;
};
export const getFormattedPreChatFields = ({ preChatFields }) => {
return preChatFields.map(item => {
return {
...item,
label: getLabel({
key: standardFieldKeys[item.name]
? standardFieldKeys[item.name].key
: item.name,
label: item.label ? item.label : item.name,
}),
placeholder: getPlaceHolder({
key: standardFieldKeys[item.name]
? standardFieldKeys[item.name].key
: item.name,
placeholder: item.placeholder ? item.placeholder : item.name,
}),
};
});
};
export const getPreChatFields = ({
preChatFormOptions = {},
customAttributes = [],
}) => {
const { pre_chat_message, pre_chat_fields } = preChatFormOptions;
let customFields = {};
let preChatFields = {};
const formattedPreChatFields = getFormattedPreChatFields({
preChatFields: pre_chat_fields,
});
customFields = getCustomFields({
standardFields: { pre_chat_fields: formattedPreChatFields },
customAttributes,
});
preChatFields = [...formattedPreChatFields, ...customFields];
return {
pre_chat_message,
pre_chat_fields: preChatFields,
};
};

View file

@ -44,7 +44,7 @@ export const getPushSubscriptionPayload = subscription => ({
});
export const sendRegistrationToServer = subscription => {
if (auth.isLoggedIn()) {
if (auth.hasAuthCookie()) {
return NotificationSubscriptions.create(
getPushSubscriptionPayload(subscription)
);

View file

@ -1,7 +1,6 @@
import {
frontendURL,
conversationUrl,
accountIdFromPathname,
isValidURL,
getLoginRedirectURL,
} from '../URLHelper';
@ -39,18 +38,6 @@ describe('#URL Helpers', () => {
});
});
describe('accountIdFromPathname', () => {
it('should return account id if accont scoped url is passed', () => {
expect(accountIdFromPathname('/app/accounts/1/settings/general')).toBe(1);
});
it('should return empty string if accont scoped url not is passed', () => {
expect(accountIdFromPathname('/app/accounts/settings/general')).toBe('');
});
it('should return empty string if empty string is passed', () => {
expect(accountIdFromPathname('')).toBe('');
});
});
describe('isValidURL', () => {
it('should return true if valid url is passed', () => {
expect(isValidURL('https://chatwoot.com')).toBe(true);
@ -75,7 +62,7 @@ describe('#URL Helpers', () => {
getLoginRedirectURL('7500', {
accounts: [{ id: '7501', name: 'Test Account 7501' }],
})
).toBe('/app/');
).toBe('/app/accounts/7501/dashboard');
expect(getLoginRedirectURL('7500', null)).toBe('/app/');
});
});

View file

@ -0,0 +1,47 @@
export default {
customFields: {
pre_chat_message: 'Share your queries or comments here.',
pre_chat_fields: [
{
label: 'Email Address',
name: 'emailAddress',
type: 'email',
field_type: 'standard',
required: false,
enabled: false,
placeholder: 'Please enter your email address',
},
{
label: 'Full Name',
name: 'fullName',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
placeholder: 'Please enter your full name',
},
{
label: 'Phone Number',
name: 'phoneNumber',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
placeholder: 'Please enter your phone number',
},
],
},
customAttributes: [
{
id: 101,
attribute_description: 'Order Identifier',
attribute_display_name: 'Order Id',
attribute_display_type: 'number',
attribute_key: 'order_id',
attribute_model: 'conversation_attribute',
attribute_values: Array(0),
created_at: '2021-11-29T10:20:04.563Z',
},
],
};

View file

@ -0,0 +1,76 @@
import {
getPreChatFields,
getFormattedPreChatFields,
getCustomFields,
} from '../preChat';
import inboxFixture from './inboxFixture';
const { customFields, customAttributes } = inboxFixture;
describe('#Pre chat Helpers', () => {
describe('getPreChatFields', () => {
it('should return correct pre-chat fields form options passed', () => {
expect(getPreChatFields({ preChatFormOptions: customFields })).toEqual(
customFields
);
});
});
describe('getFormattedPreChatFields', () => {
it('should return correct custom fields', () => {
expect(
getFormattedPreChatFields({
preChatFields: customFields.pre_chat_fields,
})
).toEqual([
{
label: 'Email Address',
name: 'emailAddress',
placeholder: 'Please enter your email address',
type: 'email',
field_type: 'standard',
required: false,
enabled: false,
},
{
label: 'Full Name',
name: 'fullName',
placeholder: 'Please enter your full name',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
},
{
label: 'Phone Number',
name: 'phoneNumber',
placeholder: 'Please enter your phone number',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
},
]);
});
});
describe('getCustomFields', () => {
it('should return correct custom fields', () => {
expect(
getCustomFields({
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
customAttributes,
})
).toEqual([
{
enabled: false,
label: 'Order Id',
placeholder: 'Order Id',
name: 'order_id',
required: false,
field_type: 'conversation_attribute',
type: 'number',
values: [],
},
]);
});
});
});

View file

@ -89,7 +89,9 @@
"DELETE_MESSAGE": "You need to have atleast one condition to save"
},
"ACTION": {
"DELETE_MESSAGE": "You need to have atleast one action to save"
"DELETE_MESSAGE": "You need to have atleast one action to save",
"TEAM_MESSAGE_INPUT_PLACEHOLDER": "Enter your message here",
"TEAM_DROPDOWN_PLACEHOLDER": "Select teams"
},
"TOGGLE": {
"ACTIVATION_TITLE": "Activate Automation Rule",
@ -102,6 +104,13 @@
"DEACTIVATION_ERROR": "Could not Deactivate Automation, Please try again later",
"CONFIRMATION_LABEL": "Yes",
"CANCEL_LABEL": "No"
},
"ATTACHMENT": {
"UPLOAD_ERROR": "Could not upload attachment, Please try again",
"LABEL_IDLE": "Upload Attachment",
"LABEL_UPLOADING": "Uploading...",
"LABEL_UPLOADED": "Succesfully Uploaded",
"LABEL_UPLOAD_FAILED": "Upload Failed"
}
}
}

View file

@ -81,6 +81,7 @@
"NO_MESSAGES": "No Messages",
"NO_CONTENT": "No content available",
"HIDE_QUOTED_TEXT": "Hide Quoted Text",
"SHOW_QUOTED_TEXT": "Show Quoted Text"
"SHOW_QUOTED_TEXT": "Show Quoted Text",
"MESSAGE_READ": "Read"
}
}

View file

@ -108,6 +108,7 @@
"GO_TO_CONVERSATION_DASHBOARD": "Go to Conversation Dashboard",
"GO_TO_CONTACTS_DASHBOARD": "Go to Contacts Dashboard",
"GO_TO_REPORTS_OVERVIEW": "Go to Reports Overview",
"GO_TO_CONVERSATION_REPORTS": "Go to Conversation Reports",
"GO_TO_AGENT_REPORTS": "Go to Agent Reports",
"GO_TO_LABEL_REPORTS": "Go to Label Reports",
"GO_TO_INBOX_REPORTS": "Go to Inbox Reports",

View file

@ -187,7 +187,7 @@
}
}
},
"WHATSAPP": {
"WHATSAPP": {
"TITLE": "WhatsApp Channel",
"DESC": "Start supporting your customers via WhatsApp.",
"PROVIDERS": {
@ -211,7 +211,6 @@
"PLACEHOLDER": "API key",
"APPLY_FOR_ACCESS": "Don't have any API key? Apply for access here",
"ERROR": "Please enter a valid value."
},
"SUBMIT_BUTTON": "Create WhatsApp Channel",
"API": {
@ -433,6 +432,15 @@
},
"PRE_CHAT_FORM": {
"DESCRIPTION": "Pre chat forms enable you to capture user information before they start conversation with you.",
"SET_FIELDS": "Pre chat form fields",
"SET_FIELDS_HEADER": {
"FIELDS": "Fields",
"LABEL": "Label",
"PLACE_HOLDER":"Placeholder",
"KEY": "Key",
"TYPE": "Type",
"REQUIRED": "Required"
},
"ENABLE": {
"LABEL": "Enable pre chat form",
"OPTIONS": {
@ -441,7 +449,7 @@
}
},
"PRE_CHAT_MESSAGE": {
"LABEL": "Pre Chat Message",
"LABEL": "Pre chat message",
"PLACEHOLDER": "This message would be visible to the users along with the form"
},
"REQUIRE_EMAIL": {
@ -465,11 +473,12 @@
"VALIDATION_ERROR": "Starting time should be before closing time.",
"CHOOSE": "Choose"
},
"ALL_DAY":"All-Day"
"ALL_DAY": "All-Day"
},
"IMAP": {
"TITLE": "IMAP",
"SUBTITLE": "Set your IMAP details",
"NOTE_TEXT": "To enable SMTP, please configure IMAP.",
"UPDATE": "Update IMAP settings",
"TOGGLE_AVAILABILITY": "Enable IMAP configuration for this inbox",
"TOGGLE_HELP": "Enabling IMAP will help the user to recieve email",
@ -485,9 +494,9 @@
"LABEL": "Port",
"PLACE_HOLDER": "Port"
},
"EMAIL": {
"LABEL": "Email",
"PLACE_HOLDER": "Email"
"LOGIN": {
"LABEL": "Login",
"PLACE_HOLDER": "Login"
},
"PASSWORD": {
"LABEL": "Password",
@ -513,9 +522,9 @@
"LABEL": "Port",
"PLACE_HOLDER": "Port"
},
"EMAIL": {
"LABEL": "Email",
"PLACE_HOLDER": "Email"
"LOGIN": {
"LABEL": "Login",
"PLACE_HOLDER": "Login"
},
"PASSWORD": {
"LABEL": "Password",
@ -528,7 +537,9 @@
"ENCRYPTION": "Encryption",
"SSL_TLS": "SSL/TLS",
"START_TLS": "STARTTLS",
"OPEN_SSL_VERIFY_MODE": "Open SSL Verify Mode"
}
"OPEN_SSL_VERIFY_MODE": "Open SSL Verify Mode",
"AUTH_MECHANISM": "Authentication"
},
"NOTE": "Note: "
}
}

View file

@ -2,6 +2,29 @@
"INTEGRATION_SETTINGS": {
"HEADER": "Integrations",
"WEBHOOK": {
"SUBSCRIBED_EVENTS": "Subscribed Events",
"FORM": {
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"SUBSCRIPTIONS": {
"LABEL": "Events",
"EVENTS": {
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_STATUS_CHANGED": "Conversation Status Changed",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message created",
"MESSAGE_UPDATED": "Message updated",
"WEBWIDGET_TRIGGERED": "Live chat widget opened by the user"
}
},
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"EDIT_SUBMIT": "Update webhook",
"ADD_SUBMIT": "Create webhook"
},
"TITLE": "Webhook",
"CONFIGURE": "Configure",
"HEADER": "Webhook settings",
@ -17,35 +40,16 @@
"EDIT": {
"BUTTON_TEXT": "Edit",
"TITLE": "Edit webhook",
"CANCEL": "Cancel",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"FORM": {
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"SUBMIT": "Edit webhook"
},
"API": {
"SUCCESS_MESSAGE": "Webhook URL updated successfully",
"SUCCESS_MESSAGE": "Webhook configuration updated successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
"ADD": {
"CANCEL": "Cancel",
"TITLE": "Add new webhook",
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
"FORM": {
"END_POINT": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Example: https://example/api/webhook",
"ERROR": "Please enter a valid URL"
},
"SUBMIT": "Create webhook"
},
"API": {
"SUCCESS_MESSAGE": "Webhook added successfully",
"SUCCESS_MESSAGE": "Webhook configuration added successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
}
},
@ -57,16 +61,16 @@
},
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ",
"MESSAGE": "Are you sure to delete the webhook? (%{webhookURL})",
"YES": "Yes, Delete ",
"NO": "No, Keep it"
}
}
},
"SLACK": {
"HELP_TEXT" : {
"HELP_TEXT" : {
"TITLE": "Using Slack Integration",
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
"BODY": "<br/><p>Chatwoot will now sync all the incoming conversations into the <b><i>customer-conversations</i></b> channel inside your slack workplace.</p><p>Replying to a conversation thread in <b><i>customer-conversations</i></b> slack channel will create a response back to the customer through chatwoot.</p><p>Start the replies with <b><i>note:</i></b> to create private notes instead of replies.</p><p>If the replier on slack has an agent profile in chatwoot under the same email, the replies will be associated accordingly.</p><p>When the replier doesn't have an associated agent profile, the replies will be made from the bot profile.</p>"
}
},
"DELETE": {

View file

@ -1,6 +1,6 @@
{
"REPORT": {
"HEADER": "Overview",
"HEADER": "Conversations",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
@ -20,12 +20,14 @@
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
@ -78,7 +80,8 @@
{ "id": 2, "groupBy": "Week" },
{ "id": 3, "groupBy": "Month" },
{ "id": 4, "groupBy": "Year" }
]
],
"BUSINESS_HOURS": "Business Hours"
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",
@ -102,12 +105,14 @@
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
@ -167,12 +172,14 @@
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
@ -232,12 +239,14 @@
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
@ -297,12 +306,14 @@
"FIRST_RESPONSE_TIME": {
"NAME": "First Response Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "First Response Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_TIME": {
"NAME": "Resolution Time",
"DESC": "( Avg )",
"INFO_TEXT": "Total number of conversations used for computation:"
"INFO_TEXT": "Total number of conversations used for computation:",
"TOOLTIP_TEXT": "Resolution Time is %{metricValue} (based on %{conversationCount} conversations)"
},
"RESOLUTION_COUNT": {
"NAME": "Resolution Count",
@ -370,5 +381,33 @@
"TOOLTIP": "Total number of responses / Total number of CSAT survey messages sent * 100"
}
}
},
"OVERVIEW_REPORTS": {
"HEADER": "Overview",
"LIVE": "Live",
"ACCOUNT_CONVERSATIONS": {
"HEADER": "Open Conversations",
"LOADING_MESSAGE": "Conversations Loading...",
"TOTAL" : "Total",
"UNATTENDED": "Unattended",
"UNASSIGNED": "Unassigned"
},
"AGENT_CONVERSATIONS": {
"HEADER": "Conversations by agents",
"LOADING_MESSAGE": "Agents Loading...",
"NO_AGENTS": "There are no conversations by agents",
"TABLE_HEADER": {
"AGENT": "Agent",
"TOTAL": "Total",
"UNATTENDED": "Unattended",
"STATUS": "Status"
}
},
"AGENT_STATUS": {
"HEADER": "Agent status",
"ONLINE": "Online",
"BUSY": "Busy",
"OFFLINE": "Offline"
}
}
}
}

View file

@ -23,7 +23,7 @@
"TITLE": "Personal message signature",
"NOTE": "Create a personal message signature that would be added to all the messages you send from the platform. Use the rich content editor to create a highly personalised signature.",
"BTN_TEXT": "Save message signature",
"API_ERROR":"Couldn't save signature! Try again",
"API_ERROR": "Couldn't save signature! Try again",
"API_SUCCESS": "Signature saved successfully"
},
"MESSAGE_SIGNATURE": {
@ -127,6 +127,10 @@
"BUTTON_TEXT": "Copy",
"COPY_SUCCESSFUL": "Code copied to clipboard successfully"
},
"SHOW_MORE_BLOCK": {
"SHOW_MORE": "Show More",
"SHOW_LESS": "Show Less"
},
"FILE_BUBBLE": {
"DOWNLOAD": "Download",
"UPLOADING": "Uploading..."
@ -169,7 +173,7 @@
"NEW_LABEL": "New label",
"NEW_TEAM": "New team",
"NEW_INBOX": "New inbox",
"REPORTS_OVERVIEW": "Overview",
"REPORTS_CONVERSATION": "Conversations",
"CSAT": "CSAT",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
@ -179,7 +183,8 @@
"REPORTS_INBOX": "Inbox",
"REPORTS_TEAM": "Team",
"SET_AVAILABILITY_TITLE": "Set yourself as",
"BETA": "Beta"
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview"
},
"CREATE_ACCOUNT": {
"NO_ACCOUNT_WARNING": "Uh oh! We could not find any Chatwoot accounts. Please create a new account to continue.",

View file

@ -21,7 +21,8 @@
"PASSWORD": {
"LABEL": "Password",
"PLACEHOLDER": "Password",
"ERROR": "Password is too short"
"ERROR": "Password is too short",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "Confirm Password",

View file

@ -83,7 +83,7 @@
"SELECT_ALL": "select all agents",
"SELECTED_COUNT": "%{selected} out of %{total} agents selected.",
"BUTTON_TEXT": "Add agents",
"AGENT_VALIDATION_ERROR": "Select atleaset one agent."
"AGENT_VALIDATION_ERROR": "Select at least one agent."
},
"FINISH": {

View file

@ -15,6 +15,9 @@ export default {
chat.private !== true
).length;
},
hasUserReadMessage(createdAt, contactLastSeen) {
return !(contactLastSeen - createdAt < 0);
},
readMessages(m) {
return m.messages.filter(
chat => chat.created_at * 1000 <= m.agent_last_seen_at * 1000

View file

@ -26,4 +26,11 @@ describe('#conversationMixin', () => {
conversationMixin.methods.unReadMessages(conversationFixture.conversation)
).toEqual(conversationFixture.unReadMessages);
});
it('should return the user message read flag', () => {
const contactLastSeen = 1649856659;
const createdAt = 1649859419;
expect(
conversationMixin.methods.hasUserReadMessage(createdAt, contactLastSeen)
).toEqual(false);
});
});

View file

@ -25,7 +25,7 @@ describe('reportMixin', () => {
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5);
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
'3 Min'
'3 Min 18 Sec'
);
});

View file

@ -7,7 +7,7 @@
<li>
<span class="bullet"></span>
<span
v-html="
v-dompurify-html="
$t('MERGE_CONTACTS.SUMMARY.DELETE_WARNING', {
childContactName,
})
@ -17,7 +17,7 @@
<li>
<span class="bullet"></span>
<span
v-html="
v-dompurify-html="
$t('MERGE_CONTACTS.SUMMARY.ATTRIBUTE_WARNING', {
childContactName,
primaryContactName,

View file

@ -35,7 +35,7 @@
:reject-text="$t('DELETE_NOTE.CONFIRM.NO')"
/>
</div>
<p class="note__content" v-html="formatMessage(note || '')" />
<p v-dompurify-html="formatMessage(note || '')" class="note__content" />
</div>
</template>

View file

@ -97,7 +97,7 @@
icon="arrow-chevron-right"
@click="submit"
/>
<p class="accept--terms" v-html="termsLink"></p>
<p v-dompurify-html="termsLink" class="accept--terms"></p>
</form>
<div class="column text-center auth--footer">
<span>{{ $t('REGISTER.HAVE_AN_ACCOUNT') }}</span>
@ -126,7 +126,7 @@ import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import AuthInput from './components/AuthInput';
import AuthHeader from './components/AuthHeader';
import AuthSubmitButton from './components/AuthSubmitButton';
import { isValidPassword } from 'shared/helpers/Validators';
export default {
components: {
AuthInput,
@ -166,6 +166,7 @@ export default {
},
password: {
required,
isValidPassword,
minLength: minLength(6),
},
confirmPassword: {
@ -195,6 +196,19 @@ export default {
}
return true;
},
passwordErrorText() {
const { password } = this.$v.credentials;
if (!password.$error) {
return '';
}
if (!password.minLength) {
return this.$t('REGISTER.PASSWORD.ERROR');
}
if (!password.isValidPassword) {
return this.$t('REGISTER.PASSWORD.IS_INVALID_PASSWORD');
}
return '';
},
},
methods: {
async submit() {

View file

@ -92,7 +92,6 @@ export default {
},
},
mounted() {
this.$store.dispatch('setCurrentAccountId', this.$route.params.accountId);
window.addEventListener('resize', this.handleResize);
this.handleResize();
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);

View file

@ -13,6 +13,7 @@ export const ICON_SNOOZE_UNTIL_TOMORRROW = `<svg role="img" class="ninja-icon ni
export const ICON_CONVERSATION_DASHBOARD = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M10.55 2.532a2.25 2.25 0 0 1 2.9 0l6.75 5.692c.507.428.8 1.057.8 1.72v9.803a1.75 1.75 0 0 1-1.75 1.75h-3.5a1.75 1.75 0 0 1-1.75-1.75v-5.5a.25.25 0 0 0-.25-.25h-3.5a.25.25 0 0 0-.25.25v5.5a1.75 1.75 0 0 1-1.75 1.75h-3.5A1.75 1.75 0 0 1 3 19.747V9.944c0-.663.293-1.292.8-1.72l6.75-5.692zm1.933 1.147a.75.75 0 0 0-.966 0L4.767 9.37a.75.75 0 0 0-.267.573v9.803c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-5.5c0-.967.784-1.75 1.75-1.75h3.5c.966 0 1.75.783 1.75 1.75v5.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25V9.944a.75.75 0 0 0-.267-.573l-6.75-5.692z" fill="currentColor"></path></g></svg>`;
export const ICON_CONTACT_DASHBOARD = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438c-1.57 1.834-3.957 2.739-7.102 2.739c-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461c1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75zM12 2.004a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z" fill="currentColor"></path></g></svg>`;
export const ICON_REPORTS_OVERVIEW = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M16.749 2h4.554l.1.014l.099.028l.06.026c.08.034.153.085.219.15l.04.044l.044.057l.054.09l.039.09l.019.064l.014.064l.009.095v4.532a.75.75 0 0 1-1.493.102l-.007-.102V4.559l-6.44 6.44a.75.75 0 0 1-.976.073L13 11L9.97 8.09l-5.69 5.689a.75.75 0 0 1-1.133-.977l.073-.084l6.22-6.22a.75.75 0 0 1 .976-.072l.084.072l3.03 2.91L19.438 3.5h-2.69a.75.75 0 0 1-.742-.648l-.007-.102a.75.75 0 0 1 .648-.743L16.75 2zM3.75 17a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75zm5.75-3.25a.75.75 0 0 0-1.5 0v7.5a.75.75 0 0 0 1.5 0v-7.5zM13.75 15a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75zm5.75-4.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5z" fill="currentColor"></path></g></svg>`;
export const ICON_CONVERSATION_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>`;
export const ICON_AGENT_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M4 13.999L13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14zM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78zM4 15.499l-.1.01a.51.51 0 0 0-.254.136a.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242c.826.445 2.003.714 3.266.753l.317.005l.317-.005c1.263-.039 2.439-.308 3.266-.753c.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001zM8.5 3a4.5 4.5 0 1 1 0 9a4.5 4.5 0 0 1 0-9zm9 2a3.5 3.5 0 1 1 0 7a3.5 3.5 0 0 1 0-7zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3s3-1.346 3-3s-1.346-3-3-3zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2s2-.897 2-2s-.897-2-2-2z" fill="currentColor"></path></g></svg>`;
export const ICON_LABEL_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-8.5 8.503a3.255 3.255 0 0 1-4.597.001L3.489 16.06a3.25 3.25 0 0 1-.003-4.596l8.5-8.51A3.25 3.25 0 0 1 14.284 2h5.465zm0 1.5h-5.465c-.465 0-.91.185-1.239.513l-8.512 8.523a1.75 1.75 0 0 0 .015 2.462l4.461 4.454a1.755 1.755 0 0 0 2.477 0l8.5-8.503a1.75 1.75 0 0 0 .513-1.237V4.25a.75.75 0 0 0-.75-.75zM17 5.502a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3z" fill="currentColor"></path></g></svg>`;
export const ICON_INBOX_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5zM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5z" fill="currentColor"></path></g></svg>`;

View file

@ -13,6 +13,7 @@ import {
ICON_REPORTS_OVERVIEW,
ICON_TEAM_REPORTS,
ICON_USER_PROFILE,
ICON_CONVERSATION_REPORTS,
} from './CommandBarIcons';
import { frontendURL } from '../../../helper/URLHelper';
@ -41,6 +42,14 @@ const GO_TO_COMMANDS = [
path: accountId => `accounts/${accountId}/reports/overview`,
role: ['administrator'],
},
{
id: 'open_conversation_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_REPORTS',
icon: ICON_CONVERSATION_REPORTS,
path: accountId => `accounts/${accountId}/reports/conversation`,
role: ['administrator'],
},
{
id: 'open_agent_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',

View file

@ -9,7 +9,9 @@
{{ attribute }}
</div>
<div>
<span v-html="valueWithLink(customAttributes[attribute])"></span>
<span
v-dompurify-html="valueWithLink(customAttributes[attribute])"
></span>
</div>
</div>
<p v-if="!listOfAttributes.length">

View file

@ -59,6 +59,13 @@
</div>
<div class="row">
<div class="columns">
<div class="canned-response">
<canned-response
v-if="showCannedResponseMenu && hasSlashCommand"
:search-key="cannedResponseSearchKey"
@click="replaceTextWithCannedResponse"
/>
</div>
<div v-if="isAnEmailInbox || isAnWebWidgetInbox">
<label>
{{ $t('NEW_CONVERSATION.FORM.MESSAGE.LABEL') }}
@ -113,6 +120,7 @@ import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
import ReplyEmailHead from 'dashboard/components/widgets/conversation/ReplyEmailHead';
import CannedResponse from 'dashboard/components/widgets/conversation/CannedResponse.vue';
import alertMixin from 'shared/mixins/alertMixin';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
@ -124,6 +132,7 @@ export default {
Thumbnail,
WootMessageEditor,
ReplyEmailHead,
CannedResponse,
},
mixins: [alertMixin],
props: {
@ -141,6 +150,8 @@ export default {
name: '',
subject: '',
message: '',
showCannedResponseMenu: false,
cannedResponseSearchKey: '',
selectedInbox: '',
bccEmails: '',
ccEmails: '',
@ -211,6 +222,20 @@ export default {
);
},
},
watch: {
message(value) {
this.hasSlashCommand = value[0] === '/';
const hasNextWord = value.includes(' ');
const isShortCodeActive = this.hasSlashCommand && !hasNextWord;
if (isShortCodeActive) {
this.cannedResponseSearchKey = value.substr(1, value.length);
this.showCannedResponseMenu = true;
} else {
this.cannedResponseSearchKey = '';
this.showCannedResponseMenu = false;
}
},
},
methods: {
onCancel() {
this.$emit('cancel');
@ -244,6 +269,11 @@ export default {
}
}
},
replaceTextWithCannedResponse(message) {
setTimeout(() => {
this.message = message;
}, 50);
},
},
};
</script>
@ -251,9 +281,15 @@ export default {
<style scoped lang="scss">
.conversation--form {
padding: var(--space-normal) var(--space-large) var(--space-large);
}
.columns {
padding: 0 var(--space-smaller);
.canned-response {
position: relative;
top: var(--space-medium);
::v-deep .mention--box {
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
}
}

View file

@ -14,7 +14,7 @@
</div>
<span class="timestamp">{{ readableTime }} </span>
</div>
<p class="message-content" v-html="prepareContent(content)"></p>
<p v-dompurify-html="prepareContent(content)" class="message-content"></p>
</div>
</div>
</template>

View file

@ -3,7 +3,7 @@
<h2 class="page-sub-title">
{{ headerTitle }}
</h2>
<p class="small-12 column" v-html="headerContent"></p>
<p v-dompurify-html="headerContent" class="small-12 column"></p>
</div>
</template>

View file

@ -89,7 +89,7 @@
</div>
<div class="small-4 columns">
<span
v-html="
v-dompurify-html="
useInstallationName(
$t('AGENT_MGMT.SIDEBAR_TXT'),
globalConfig.installationName

View file

@ -76,7 +76,7 @@
</div>
</div>
<div class="small-4 columns">
<span v-html="$t('ATTRIBUTES_MGMT.SIDEBAR_TXT')"></span>
<span v-dompurify-html="$t('ATTRIBUTES_MGMT.SIDEBAR_TXT')"></span>
</div>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<edit-attribute

View file

@ -192,10 +192,11 @@ export default {
$each: {
action_params: {
required: requiredIf(prop => {
if (prop.action_name === 'send_email_to_team') return true;
return !(
prop.action_name === 'mute_conversation' ||
prop.action_name === 'snooze_convresation' ||
prop.action_name === 'resolve_convresation'
prop.action_name === 'snooze_conversation' ||
prop.action_name === 'resolve_conversation'
);
}),
},
@ -361,6 +362,7 @@ export default {
getActionDropdownValues(type) {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return this.$store.getters['teams/getTeams'];
case 'add_label':
return this.$store.getters['labels/getLabels'].map(i => {
@ -443,6 +445,8 @@ export default {
return true;
},
showActionInput(actionName) {
if (actionName === 'send_email_to_team' || actionName === 'send_message')
return false;
const type = AUTOMATION_ACTION_TYPES.find(
action => action.key === actionName
).inputType;

View file

@ -101,6 +101,12 @@
showActionInput(automation.actions[i].action_name)
"
:v="$v.automation.actions.$each[i]"
:initial-file-name="
getFileName(
automation.actions[i].action_params[0],
automation.actions[i].action_name
)
"
@removeAction="removeAction(i)"
/>
<div class="filter-actions">
@ -198,8 +204,8 @@ export default {
required: requiredIf(prop => {
return !(
prop.action_name === 'mute_conversation' ||
prop.action_name === 'snooze_convresation' ||
prop.action_name === 'resolve_convresation'
prop.action_name === 'snooze_conversation' ||
prop.action_name === 'resolve_conversation'
);
}),
},
@ -360,6 +366,7 @@ export default {
getActionDropdownValues(type) {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return this.$store.getters['teams/getTeams'];
case 'add_label':
return this.$store.getters['labels/getLabels'].map(i => {
@ -475,6 +482,15 @@ export default {
actionParams = [
...this.getActionDropdownValues(action.action_name),
].filter(item => [...action.action_params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getActionDropdownValues(action.action_name),
].filter(item =>
[...action.action_params[0].team_ids].includes(item.id)
),
message: action.action_params[0].message,
};
} else actionParams = [...action.action_params];
}
return {
@ -489,12 +505,23 @@ export default {
};
},
showActionInput(actionName) {
if (actionName === 'send_email_to_team' || actionName === 'send_message')
return false;
const type = AUTOMATION_ACTION_TYPES.find(
action => action.key === actionName
).inputType;
if (type === null) return false;
return true;
},
getFileName(id, actionType) {
if (!id) return '';
if (actionType === 'send_attachment') {
const file = this.automation.files.find(item => item.blob_id === id);
// replace `blob_id.toString()` with file name once api is fixed.
if (file) return file.filename.toString();
}
return '';
},
},
};
</script>

View file

@ -34,19 +34,10 @@
<td>{{ automation.name }}</td>
<td>{{ automation.description }}</td>
<td>
<button
type="button"
class="toggle-button"
:class="{ active: automation.active }"
role="switch"
:aria-checked="automation.active.toString()"
@click="toggleAutomation(automation, automation.active)"
>
<span
aria-hidden="true"
:class="{ active: automation.active }"
></span>
</button>
<woot-switch
:value="automation.active"
@input="toggleAutomation(automation, automation.active)"
/>
</td>
<td>{{ readableTime(automation.created_on) }}</td>
<td class="button-wrapper">
@ -90,7 +81,7 @@
</div>
<div class="small-4 columns">
<span v-html="$t('AUTOMATION.SIDEBAR_TXT')"></span>
<span v-dompurify-html="$t('AUTOMATION.SIDEBAR_TXT')"></span>
</div>
</div>
<woot-modal
@ -238,7 +229,6 @@ export default {
mode === 'EDIT'
? this.$t('AUTOMATION.EDIT.API.SUCCESS_MESSAGE')
: this.$t('AUTOMATION.ADD.API.SUCCESS_MESSAGE');
await await this.$store.dispatch(action, payload);
this.showAlert(this.$t(successMessage));
this.hideAddPopup();
@ -263,7 +253,7 @@ export default {
: this.$t('AUTOMATION.TOGGLE.ACTIVATION_DESCRIPTION', {
automationName: automation.name,
});
// Check if uses confirms to proceed
// Check if user confirms to proceed
const ok = await this.$refs.confirmDialog.showConfirmation();
if (ok) {
await await this.$store.dispatch('automations/update', {
@ -290,41 +280,4 @@ export default {
.automation__status-checkbox {
margin: 0;
}
.toggle-button {
background-color: var(--s-200);
position: relative;
display: inline-flex;
height: 19px;
width: 34px;
border: 2px solid transparent;
border-radius: var(--border-radius-large);
cursor: pointer;
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
flex-shrink: 0;
}
.toggle-button.active {
background-color: var(--w-500);
}
.toggle-button span {
--space-one-point-five: 1.5rem;
height: var(--space-one-point-five);
width: var(--space-one-point-five);
display: inline-block;
background-color: var(--white);
box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
transform: translate(0, 0);
border-radius: 100%;
transition-property: transform;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
.toggle-button span.active {
transform: translate(var(--space-one-point-five), var(--space-zero));
}
</style>

View file

@ -64,6 +64,13 @@ export const AUTOMATIONS = {
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'inbox_id',
name: 'Inbox',
attributeI18nKey: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
],
actions: [
{
@ -76,11 +83,16 @@ export const AUTOMATIONS = {
name: 'Add a label',
attributeI18nKey: 'ADD_LABEL',
},
// {
// key: 'send_email_to_team',
// name: 'Send an email to team',
// attributeI18nKey: 'SEND_MESSAGE',
// },
{
key: 'send_email_to_team',
name: 'Send an email to team',
attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'Send a message',
attributeI18nKey: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'Send an email transcript',
@ -92,12 +104,13 @@ export const AUTOMATIONS = {
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'snooze_convresation',
key: 'snooze_conversation',
name: 'Snooze conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'resolve_convresation',
key: 'resolve_conversation',
name: 'Resolve conversation',
attributeI18nKey: 'RESOLVE_CONVERSATION',
},
@ -106,6 +119,11 @@ export const AUTOMATIONS = {
name: 'Send Webhook Event',
attributeI18nKey: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
name: 'Send Attachment',
attributeI18nKey: 'SEND_ATTACHMENT',
},
],
},
conversation_created: {
@ -132,12 +150,19 @@ export const AUTOMATIONS = {
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'referrer',
key: 'referer',
name: 'Referrer Link',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'inbox_id',
name: 'Inbox',
attributeI18nKey: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
],
actions: [
{
@ -150,11 +175,16 @@ export const AUTOMATIONS = {
name: 'Assign an agent',
attributeI18nKey: 'ASSIGN_AGENT',
},
// {
// key: 'send_email_to_team',
// name: 'Send an email to team',
// attributeI18nKey: 'SEND_MESSAGE',
// },
{
key: 'send_email_to_team',
name: 'Send an email to team',
attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'Send a message',
attributeI18nKey: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'Send an email transcript',
@ -166,12 +196,12 @@ export const AUTOMATIONS = {
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'snooze_convresation',
key: 'snooze_conversation',
name: 'Snooze conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'resolve_convresation',
key: 'resolve_conversation',
name: 'Resolve conversation',
attributeI18nKey: 'RESOLVE_CONVERSATION',
},
@ -180,6 +210,11 @@ export const AUTOMATIONS = {
name: 'Send Webhook Event',
attributeI18nKey: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
name: 'Send Attachment',
attributeI18nKey: 'SEND_ATTACHMENT',
},
],
},
conversation_updated: {
@ -226,6 +261,13 @@ export const AUTOMATIONS = {
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'inbox_id',
name: 'Inbox',
attributeI18nKey: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
],
actions: [
{
@ -238,11 +280,16 @@ export const AUTOMATIONS = {
name: 'Assign an agent',
attributeI18nKey: 'ASSIGN_AGENT',
},
// {
// key: 'send_email_to_team',
// name: 'Send an email to team',
// attributeI18nKey: 'SEND_MESSAGE',
// },
{
key: 'send_email_to_team',
name: 'Send an email to team',
attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'Send a message',
attributeI18nKey: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'Send an email transcript',
@ -254,12 +301,12 @@ export const AUTOMATIONS = {
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'snooze_convresation',
key: 'snooze_conversation',
name: 'Snooze conversation',
attributeI18nKey: 'MUTE_CONVERSATION',
},
{
key: 'resolve_convresation',
key: 'resolve_conversation',
name: 'Resolve conversation',
attributeI18nKey: 'RESOLVE_CONVERSATION',
},
@ -268,6 +315,11 @@ export const AUTOMATIONS = {
name: 'Send Webhook Event',
attributeI18nKey: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
name: 'Send Attachment',
attributeI18nKey: 'SEND_ATTACHMENT',
},
],
},
};
@ -298,11 +350,11 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'Add a label',
inputType: 'multi_select',
},
// {
// key: 'send_email_to_team',
// label: 'Send an email to team',
// inputType: 'multi_select',
// },
{
key: 'send_email_to_team',
label: 'Send an email to team',
inputType: 'team_message',
},
{
key: 'send_email_transcript',
label: 'Send an email transcript',
@ -314,12 +366,12 @@ export const AUTOMATION_ACTION_TYPES = [
inputType: null,
},
{
key: 'snooze_convresation',
key: 'snooze_conversation',
label: 'Snooze conversation',
inputType: null,
},
{
key: 'resolve_convresation',
key: 'resolve_conversation',
label: 'Resolve conversation',
inputType: null,
},
@ -328,4 +380,14 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'Send Webhook Event',
inputType: 'url',
},
{
key: 'send_attachment',
label: 'Send Attachment',
inputType: 'attachment',
},
{
key: 'send_message',
label: 'Send a message',
inputType: 'textarea',
},
];

View file

@ -76,7 +76,7 @@
</div>
<div class="small-4 columns">
<span v-html="$t('CANNED_MGMT.SIDEBAR_TXT')"></span>
<span v-dompurify-html="$t('CANNED_MGMT.SIDEBAR_TXT')"></span>
</div>
</div>
<!-- Add Agent -->

View file

@ -3,6 +3,7 @@
<settings-section
:title="$t('INBOX_MGMT.IMAP.TITLE')"
:sub-title="$t('INBOX_MGMT.IMAP.SUBTITLE')"
:note="$t('INBOX_MGMT.IMAP.NOTE_TEXT')"
>
<form @submit.prevent="updateInbox">
<label for="toggle-imap-enable">
@ -33,12 +34,12 @@
@blur="$v.port.$touch"
/>
<woot-input
v-model="email"
:class="{ error: $v.email.$error }"
v-model="login"
:class="{ error: $v.login.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.IMAP.EMAIL.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.EMAIL.PLACE_HOLDER')"
@blur="$v.email.$touch"
:label="$t('INBOX_MGMT.IMAP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.IMAP.LOGIN.PLACE_HOLDER')"
@blur="$v.login.$touch"
/>
<woot-input
v-model="password"
@ -72,7 +73,7 @@
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import { required, minLength, email } from 'vuelidate/lib/validators';
import { required, minLength } from 'vuelidate/lib/validators';
export default {
components: {
@ -90,7 +91,7 @@ export default {
isIMAPEnabled: false,
address: '',
port: '',
email: '',
login: '',
password: '',
isSSLEnabled: true,
};
@ -98,7 +99,7 @@ export default {
validations: {
address: { required },
port: { required, minLength: minLength(2) },
email: { required, email },
login: { required },
password: { required },
},
computed: {
@ -118,28 +119,28 @@ export default {
imap_enabled,
imap_address,
imap_port,
imap_email,
imap_login,
imap_password,
imap_enable_ssl,
} = this.inbox;
this.isIMAPEnabled = imap_enabled;
this.address = imap_address;
this.port = imap_port;
this.email = imap_email;
this.login = imap_login;
this.password = imap_password;
this.isSSLEnabled = imap_enable_ssl;
},
async updateInbox() {
try {
this.loading = true;
const payload = {
let payload = {
id: this.inbox.id,
formData: false,
channel: {
imap_enabled: this.isIMAPEnabled,
imap_address: this.address,
imap_port: this.port,
imap_email: this.email,
imap_login: this.login,
imap_password: this.password,
imap_enable_ssl: this.isSSLEnabled,
imap_inbox_synced_at: this.isIMAPEnabled
@ -147,6 +148,11 @@ export default {
: undefined,
},
};
if (!this.isIMAPEnabled) {
payload.channel.smtp_enabled = false;
}
await this.$store.dispatch('inboxes/updateInboxIMAP', payload);
this.showAlert(this.$t('INBOX_MGMT.IMAP.EDIT.SUCCESS_MESSAGE'));
} catch (error) {

View file

@ -102,7 +102,7 @@
<div class="small-4 columns">
<span
v-html="
v-dompurify-html="
useInstallationName(
$t('INBOX_MGMT.SIDEBAR_TXT'),
globalConfig.installationName

View file

@ -0,0 +1,91 @@
<template>
<draggable v-model="preChatFields" tag="tbody">
<tr v-for="(item, index) in preChatFields" :key="index">
<td class="pre-chat-field"><fluent-icon icon="drag" /></td>
<td class="pre-chat-field">
<woot-switch
:value="item['enabled']"
@input="handlePreChatFieldOptions($event, 'enabled', item)"
/>
</td>
<td class="pre-chat-field" :class="{ 'disabled-text': !item['enabled'] }">
{{ item.name }}
</td>
<td class="pre-chat-field" :class="{ 'disabled-text': !item['enabled'] }">
{{ item.type }}
</td>
<td class="pre-chat-field">
<input
v-model="item['required']"
type="checkbox"
:value="`${item.name}-required`"
:disabled="!item['enabled']"
@click="handlePreChatFieldOptions($event, 'required', item)"
/>
</td>
<td class="pre-chat-field" :class="{ 'disabled-text': !item['enabled'] }">
<input
v-model.trim="item.label"
type="text"
:disabled="isFieldEditable(item)"
/>
</td>
<td class="pre-chat-field" :class="{ 'disabled-text': !item['enabled'] }">
<input
v-model.trim="item.placeholder"
type="text"
:disabled="isFieldEditable(item)"
/>
</td>
</tr>
</draggable>
</template>
<script>
import draggable from 'vuedraggable';
import { standardFieldKeys } from 'dashboard/helper/preChat';
export default {
components: { draggable },
props: {
preChatFields: {
type: Array,
default: () => [],
},
handlePreChatFieldOptions: {
type: Function,
default: () => {},
},
},
methods: {
isFieldEditable(item) {
return !!standardFieldKeys[item.name] || !item.enabled;
},
},
};
</script>
<style scoped lang="scss">
.pre-chat-field {
padding: var(--space-normal) var(--space-small);
svg {
display: flex;
align-items: center;
}
}
.disabled-text {
color: var(--s-500);
}
table {
thead th {
text-transform: none;
}
input {
font-size: var(--font-size-small);
margin-bottom: 0;
}
}
checkbox {
margin: 0;
}
</style>

View file

@ -1,10 +1,10 @@
<template>
<div class="settings--content">
<div class="prechat--title">
<div class="pre-chat--title">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.DESCRIPTION') }}
</div>
<form class="medium-6" @submit.prevent="updateInbox">
<label class="medium-9 columns">
<form @submit.prevent="updateInbox">
<label class="medium-3 columns">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.ENABLE.LABEL') }}
<select v-model="preChatFormEnabled">
<option :value="true">
@ -15,28 +15,55 @@
</option>
</select>
</label>
<label class="medium-9">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
<textarea
v-model.trim="preChatMessage"
type="text"
:placeholder="
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
"
/>
</label>
<div>
<input
v-model="preChatFieldOptions"
type="checkbox"
value="requireEmail"
@input="handlePreChatFieldOptions"
/>
<label for="requireEmail">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.REQUIRE_EMAIL.LABEL') }}
<div v-if="preChatFormEnabled">
<label class="medium-3 columns">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.LABEL') }}
<textarea
v-model.trim="preChatMessage"
type="text"
:placeholder="
$t('INBOX_MGMT.PRE_CHAT_FORM.PRE_CHAT_MESSAGE.PLACEHOLDER')
"
/>
</label>
<label class="medium-8 columns">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS') }}
<table class="table table-striped w-full">
<thead class="thead-dark">
<tr>
<th scope="col"></th>
<th scope="col"></th>
<th scope="col">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.KEY') }}
</th>
<th scope="col">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.TYPE') }}
</th>
<th scope="col">
{{
$t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.REQUIRED')
}}
</th>
<th scope="col">
{{ $t('INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.LABEL') }}
</th>
<th scope="col">
{{
$t(
'INBOX_MGMT.PRE_CHAT_FORM.SET_FIELDS_HEADER.PLACE_HOLDER'
)
}}
</th>
</tr>
</thead>
<pre-chat-fields
:pre-chat-fields="preChatFields"
:handle-pre-chat-field-options="handlePreChatFieldOptions"
/>
</table>
</label>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
@ -47,8 +74,13 @@
<script>
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import PreChatFields from './PreChatFields.vue';
import { getPreChatFields, standardFieldKeys } from 'dashboard/helper/preChat';
export default {
components: {
PreChatFields,
},
mixins: [alertMixin],
props: {
inbox: {
@ -60,11 +92,21 @@ export default {
return {
preChatFormEnabled: false,
preChatMessage: '',
preChatFieldOptions: [],
preChatFields: [],
};
},
computed: {
...mapGetters({ uiFlags: 'inboxes/getUIFlags' }),
...mapGetters({
uiFlags: 'inboxes/getUIFlags',
customAttributes: 'attributes/getAttributes',
}),
preChatFieldOptions() {
const { pre_chat_form_options: preChatFormOptions } = this.inbox;
return getPreChatFields({
preChatFormOptions,
customAttributes: this.customAttributes,
});
},
},
watch: {
inbox() {
@ -76,25 +118,26 @@ export default {
},
methods: {
setDefaults() {
const {
pre_chat_form_enabled: preChatFormEnabled,
pre_chat_form_options: preChatFormOptions,
} = this.inbox;
const { pre_chat_form_enabled: preChatFormEnabled } = this.inbox;
this.preChatFormEnabled = preChatFormEnabled;
const { pre_chat_message: preChatMessage, require_email: requireEmail } =
preChatFormOptions || {};
const {
pre_chat_message: preChatMessage,
pre_chat_fields: preChatFields,
} = this.preChatFieldOptions || {};
this.preChatMessage = preChatMessage;
if (requireEmail) {
this.preChatFieldOptions = ['requireEmail'];
}
this.preChatFields = preChatFields;
},
handlePreChatFieldOptions(event) {
if (this.preChatFieldOptions.includes(event.target.value)) {
this.preChatFieldOptions = [];
} else {
this.preChatFieldOptions = [event.target.value];
}
isFieldEditable(item) {
return !!standardFieldKeys[item.name] || !item.enabled;
},
handlePreChatFieldOptions(event, type, item) {
this.preChatFields.forEach((field, index) => {
if (field.name === item.name) {
this.preChatFields[index][type] = !item[type];
}
});
},
async updateInbox() {
try {
const payload = {
@ -104,7 +147,7 @@ export default {
pre_chat_form_enabled: this.preChatFormEnabled,
pre_chat_form_options: {
pre_chat_message: this.preChatMessage,
require_email: this.preChatFieldOptions.includes('requireEmail'),
pre_chat_fields: this.preChatFields,
},
},
};
@ -117,12 +160,11 @@ export default {
},
};
</script>
<style scoped>
<style scoped lang="scss">
.settings--content {
font-size: var(--font-size-default);
}
.prechat--title {
.pre-chat--title {
margin: var(--space-medium) 0 var(--space-slab);
}
</style>

View file

@ -422,7 +422,7 @@
</settings-section>
</div>
<imap-settings :inbox="inbox" />
<smtp-settings :inbox="inbox" />
<smtp-settings v-if="inbox.imap_enabled" :inbox="inbox" />
</div>
</div>
<div v-if="selectedTabKey === 'preChatForm'">

View file

@ -33,12 +33,12 @@
@blur="$v.port.$touch"
/>
<woot-input
v-model="email"
:class="{ error: $v.email.$error }"
v-model="login"
:class="{ error: $v.login.$error }"
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.EMAIL.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.EMAIL.PLACE_HOLDER')"
@blur="$v.email.$touch"
:label="$t('INBOX_MGMT.SMTP.LOGIN.LABEL')"
:placeholder="$t('INBOX_MGMT.SMTP.LOGIN.PLACE_HOLDER')"
@blur="$v.login.$touch"
/>
<woot-input
v-model="password"
@ -69,6 +69,13 @@
:options="openSSLVerifyModes"
:action="handleSSLModeChange"
/>
<single-select-dropdown
class="medium-9 columns"
:label="$t('INBOX_MGMT.SMTP.AUTH_MECHANISM')"
:selected="authMechanism"
:options="authMechanisms"
:action="handleAuthMechanismChange"
/>
</div>
<woot-submit-button
:button-text="$t('INBOX_MGMT.SMTP.UPDATE')"
@ -84,7 +91,7 @@
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import SettingsSection from 'dashboard/components/SettingsSection';
import { required, minLength, email } from 'vuelidate/lib/validators';
import { required, minLength } from 'vuelidate/lib/validators';
import InputRadioGroup from './components/InputRadioGroup';
import SingleSelectDropdown from './components/SingleSelectDropdown';
@ -106,12 +113,13 @@ export default {
isSMTPEnabled: false,
address: '',
port: '',
email: '',
login: '',
password: '',
domain: '',
ssl: false,
starttls: true,
openSSLVerifyMode: 'none',
authMechanism: 'login',
encryptionProtocols: [
{ id: 'ssl', title: 'SSL/TLS', checked: false },
{ id: 'starttls', title: 'STARTTLS', checked: true },
@ -122,6 +130,15 @@ export default {
{ key: 3, value: 'client_once' },
{ key: 4, value: 'fail_if_no_peer_cert' },
],
authMechanisms: [
{ key: 1, value: 'plain' },
{ key: 2, value: 'login' },
{ key: 3, value: 'cram-md5' },
{ key: 4, value: 'xoauth' },
{ key: 5, value: 'xoauth2' },
{ key: 6, value: 'ntlm' },
{ key: 7, value: 'gssapi' },
],
};
},
validations: {
@ -130,7 +147,7 @@ export default {
required,
minLength: minLength(2),
},
email: { required, email },
login: { required },
password: { required },
domain: { required },
},
@ -151,22 +168,24 @@ export default {
smtp_enabled,
smtp_address,
smtp_port,
smtp_email,
smtp_login,
smtp_password,
smtp_domain,
smtp_enable_starttls_auto,
smtp_enable_ssl_tls,
smtp_openssl_verify_mode,
smtp_authentication,
} = this.inbox;
this.isSMTPEnabled = smtp_enabled;
this.address = smtp_address;
this.port = smtp_port;
this.email = smtp_email;
this.login = smtp_login;
this.password = smtp_password;
this.domain = smtp_domain;
this.starttls = smtp_enable_starttls_auto;
this.ssl = smtp_enable_ssl_tls;
this.openSSLVerifyMode = smtp_openssl_verify_mode;
this.authMechanism = smtp_authentication;
this.encryptionProtocols = [
{ id: 'ssl', title: 'SSL/TLS', checked: smtp_enable_ssl_tls },
@ -189,6 +208,9 @@ export default {
handleSSLModeChange(mode) {
this.openSSLVerifyMode = mode;
},
handleAuthMechanismChange(mode) {
this.authMechanism = mode;
},
async updateInbox() {
try {
const payload = {
@ -198,12 +220,13 @@ export default {
smtp_enabled: this.isSMTPEnabled,
smtp_address: this.address,
smtp_port: this.port,
smtp_email: this.email,
smtp_login: this.login,
smtp_password: this.password,
smtp_domain: this.domain,
smtp_enable_ssl_tls: this.ssl,
smtp_enable_starttls_auto: this.starttls,
smtp_openssl_verify_mode: this.openSSLVerifyMode,
smtp_authentication: this.authMechanism,
},
};
await this.$store.dispatch('inboxes/updateInboxSMTP', payload);

View file

@ -50,7 +50,7 @@
<b>{{ integration.name }}</b>
</p>
<p
v-html="
v-dompurify-html="
$t(
`INTEGRATION_APPS.SIDEBAR_DESCRIPTION.${integration.name.toUpperCase()}`,
{ installationName: globalConfig.installationName }

View file

@ -1,108 +0,0 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.TITLE')"
/>
<form class="row" @submit.prevent="editWebhook">
<div class="medium-12 columns">
<label :class="{ error: $v.endPoint.$error }">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.END_POINT.LABEL') }}
<input
v-model.trim="endPoint"
type="text"
name="endPoint"
:placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.END_POINT.PLACEHOLDER')
"
@input="$v.endPoint.$touch"
/>
<span v-if="$v.endPoint.$error" class="message">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.END_POINT.ERROR') }}
</span>
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:is-disabled="
$v.endPoint.$invalid || uiFlags.updatingItem || endPoint === url
"
:is-loading="uiFlags.updatingItem"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.FORM.SUBMIT') }}
</woot-button>
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.CANCEL') }}
</woot-button>
</div>
</div>
</form>
</div>
</template>
<script>
import { required, url, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
export default {
mixins: [alertMixin],
props: {
id: {
type: Number,
required: true,
},
url: {
type: String,
required: true,
},
onClose: {
type: Function,
required: true,
},
},
data() {
return {
alertMessage: '',
endPoint: this.url,
webhookId: this.id,
};
},
validations: {
endPoint: {
required,
minLength: minLength(7),
url,
},
},
computed: {
...mapGetters({ uiFlags: 'webhooks/getUIFlags' }),
},
methods: {
resetForm() {
this.endPoint = '';
this.$v.endPoint.$reset();
},
async editWebhook() {
try {
await this.$store.dispatch('webhooks/update', {
webhook: { url: this.endPoint },
id: this.webhookId,
});
this.alertMessage = this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.SUCCESS_MESSAGE'
);
this.resetForm();
this.onClose();
} catch (error) {
this.alertMessage =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
} finally {
this.showAlert(this.alertMessage);
}
},
},
};
</script>

View file

@ -4,7 +4,9 @@
<div class="integration--description">
<h5>{{ $t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.TITLE') }}</h5>
<p>
<span v-html="$t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.BODY')"></span>
<span
v-dompurify-html="$t('INTEGRATION_SETTINGS.SLACK.HELP_TEXT.BODY')"
></span>
</p>
</div>
</div>

View file

@ -1,121 +0,0 @@
<template>
<modal :show.sync="show" :on-close="onClose" :close-on-backdrop-click="false">
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.DESC'),
globalConfig.installationName
)
"
/>
<form class="row" @submit.prevent="addWebhook">
<div class="medium-12 columns">
<label :class="{ error: $v.endPoint.$error }">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.END_POINT.LABEL') }}
<input
v-model.trim="endPoint"
type="text"
name="endPoint"
:placeholder="
$t(
'INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.END_POINT.PLACEHOLDER'
)
"
@input="$v.endPoint.$touch"
/>
<span v-if="$v.endPoint.$error" class="message">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.END_POINT.ERROR') }}
</span>
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:disabled="$v.endPoint.$invalid || addWebHook.showLoading"
:is-loading="addWebHook.showLoading"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.FORM.SUBMIT') }}
</woot-button>
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.ADD.CANCEL') }}
</woot-button>
</div>
</div>
</form>
</div>
</modal>
</template>
<script>
import { required, url, minLength } from 'vuelidate/lib/validators';
import alertMixin from 'shared/mixins/alertMixin';
import Modal from '../../../../components/Modal';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
export default {
components: {
Modal,
},
mixins: [alertMixin, globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
data() {
return {
endPoint: '',
addWebHook: {
showAlert: false,
showLoading: false,
},
show: true,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
validations: {
endPoint: {
required,
minLength: minLength(7),
url,
},
},
methods: {
resetForm() {
this.endPoint = '';
this.$v.endPoint.$reset();
},
async addWebhook() {
this.addWebHook.showLoading = true;
try {
await this.$store.dispatch('webhooks/create', {
webhook: { url: this.endPoint },
});
this.addWebHook.showLoading = false;
this.addWebHook.message = this.$t(
'INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE'
);
this.resetForm();
this.onClose();
} catch (error) {
this.addWebHook.showLoading = false;
this.addWebHook.message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
} finally {
this.addWebHook.showLoading = false;
this.showAlert(this.addWebHook.message);
}
},
},
};
</script>

View file

@ -0,0 +1,61 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.TITLE')"
/>
<webhook-form
:value="value"
:is-submitting="uiFlags.updatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.EDIT_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [alertMixin],
props: {
value: {
type: Object,
required: true,
},
id: {
type: [Number, String],
required: true,
},
onClose: {
type: Function,
required: true,
},
},
computed: {
...mapGetters({ uiFlags: 'webhooks/getUIFlags' }),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/update', {
webhook,
id: this.id,
});
this.showAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const alertMessage =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
this.showAlert(alertMessage);
}
},
},
};
</script>

View file

@ -4,7 +4,7 @@
color-scheme="success"
class-names="button--fixed-right-top"
icon="add-circle"
@click="openAddPopup()"
@click="openAddPopup"
>
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT') }}
</woot-button>
@ -37,42 +37,21 @@
</th>
</thead>
<tbody>
<tr v-for="(webHookItem, index) in records" :key="webHookItem.id">
<td class="webhook-link">
{{ webHookItem.url }}
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')
"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="openEditPopup(webHookItem)"
>
</woot-button>
<woot-button
v-tooltip.top="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')
"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
@click="openDeletePopup(webHookItem, index)"
>
</woot-button>
</td>
</tr>
<webhook-row
v-for="(webHookItem, index) in records"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</div>
<div class="small-4 columns">
<span
v-html="
v-dompurify-html="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.SIDEBAR_TXT'),
globalConfig.installationName
@ -83,24 +62,27 @@
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<new-webhook :on-close="hideAddPopup" />
<new-webhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<edit-webhook
v-if="showEditPopup"
:id="selectedWebHook.id"
:url="selectedWebHook.url"
:value="selectedWebHook"
:on-close="hideEditPopup"
/>
</woot-modal>
<woot-delete-modal
:show.sync="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.TITLE')"
:message="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE')"
:message="
$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.MESSAGE', {
webhookURL: selectedWebHook.url,
})
"
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
/>
@ -112,11 +94,13 @@ import NewWebhook from './NewWebHook';
import EditWebhook from './EditWebHook';
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import WebhookRow from './WebhookRow';
export default {
components: {
NewWebhook,
EditWebhook,
WebhookRow,
},
mixins: [alertMixin, globalConfigMixin],
data() {
@ -179,11 +163,3 @@ export default {
},
};
</script>
<style scoped lang="scss">
.webhook-link {
word-break: break-word;
}
.button-wrapper button:nth-child(2) {
margin-left: var(--space-normal);
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<div class="column content-box">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
useInstallationName(
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'),
globalConfig.installationName
)
"
/>
<webhook-form
:is-submitting="uiFlags.creatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [alertMixin, globalConfigMixin],
props: {
onClose: {
type: Function,
required: true,
},
},
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
uiFlags: 'webhooks/getUIFlags',
}),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
this.showAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
this.showAlert(message);
}
},
},
};
</script>

View file

@ -0,0 +1,108 @@
<template>
<form class="row" @submit.prevent="onSubmit">
<div class="medium-12 columns">
<label :class="{ error: $v.url.$error }">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.LABEL') }}
<input
v-model.trim="url"
type="text"
name="url"
:placeholder="
$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER')
"
@input="$v.url.$touch"
/>
<span v-if="$v.url.$error" class="message">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.ERROR') }}
</span>
</label>
<label :class="{ error: $v.url.$error }" class="margin-bottom-small">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label>
<div v-for="event in supportedWebhookEvents" :key="event">
<input
:id="event"
v-model="subscriptions"
type="checkbox"
:value="event"
name="subscriptions"
class="margin-right-small"
/>
<span class="fs-small">
{{ `${getEventLabel(event)} (${event})` }}
</span>
</div>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:disabled="$v.$invalid || isSubmitting"
:is-loading="isSubmitting"
>
{{ submitLabel }}
</woot-button>
<woot-button class="button clear" @click.prevent="$emit('cancel')">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.CANCEL') }}
</woot-button>
</div>
</div>
</form>
</template>
<script>
import { required, url, minLength } from 'vuelidate/lib/validators';
import webhookMixin from './webhookMixin';
const SUPPORTED_WEBHOOK_EVENTS = [
'conversation_created',
'conversation_status_changed',
'conversation_updated',
'message_created',
'message_updated',
'webwidget_triggered',
];
export default {
mixins: [webhookMixin],
props: {
value: {
type: Object,
default: () => ({}),
},
isSubmitting: {
type: Boolean,
default: false,
},
submitLabel: {
type: String,
required: true,
},
},
validations: {
url: {
required,
minLength: minLength(7),
url,
},
subscriptions: {
required,
},
},
data() {
return {
url: this.value.url || '',
subscriptions: this.value.subscriptions || [],
supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS,
};
},
methods: {
onSubmit() {
this.$emit('submit', {
url: this.url,
subscriptions: this.subscriptions,
});
},
},
};
</script>

View file

@ -0,0 +1,83 @@
<template>
<tr>
<td>
<div class="webhook--link">{{ webhook.url }}</div>
<span class="webhook--subscribed-events">
<span class="webhook--subscribed-label">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<show-more :text="subscribedEvents" :limit="60" />
</span>
</td>
<td class="button-wrapper">
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
variant="smooth"
size="tiny"
color-scheme="secondary"
icon="edit"
@click="$emit('edit', webhook)"
>
</woot-button>
<woot-button
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="dismiss-circle"
@click="$emit('delete', webhook, index)"
>
</woot-button>
</td>
</tr>
</template>
<script>
import webhookMixin from './webhookMixin';
import ShowMore from 'dashboard/components/widgets/ShowMore';
export default {
components: { ShowMore },
mixins: [webhookMixin],
props: {
webhook: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
subscribedEvents() {
const { subscriptions } = this.webhook;
return subscriptions.map(event => this.getEventLabel(event)).join(', ');
},
},
};
</script>
<style scoped lang="scss">
.webhook--link {
color: var(--s-700);
font-weight: var(--font-weight-medium);
word-break: break-word;
}
.webhook--subscribed-events {
color: var(--s-500);
font-size: var(--font-size-mini);
}
.webhook--subscribed-label {
font-weight: var(--font-weight-medium);
}
.button-wrapper {
max-width: var(--space-mega);
min-width: auto;
button:nth-child(2) {
margin-left: var(--space-normal);
}
}
</style>

View file

@ -0,0 +1,26 @@
import { createWrapper } from '@vue/test-utils';
import webhookMixin from '../webhookMixin';
import Vue from 'vue';
describe('webhookMixin', () => {
describe('#getEventLabel', () => {
it('returns correct i18n translation:', () => {
const Component = {
render() {},
title: 'WebhookComponent',
mixins: [webhookMixin],
methods: {
$t(text) {
return text;
},
},
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
const wrapper = createWrapper(vm);
expect(wrapper.vm.getEventLabel('message_created')).toEqual(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
);
});
});
});

View file

@ -0,0 +1,10 @@
export default {
methods: {
getEventLabel(event) {
const eventName = event.toUpperCase();
return this.$t(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`
);
},
},
};

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