Merge branch 'chore/upgrade-to-postcss-8' of https://github.com/chatwoot/chatwoot into feat/widget-multi-getters

This commit is contained in:
Nithin David 2021-09-29 17:34:50 +05:30
commit 7bdfb5b075
151 changed files with 2383 additions and 241 deletions

3
.bundler-audit.yml Normal file
View file

@ -0,0 +1,3 @@
---
ignore:
- CVE-2021-41098 # https://github.com/chatwoot/chatwoot/issues/3097 (update once azure blob storage is updated)

View file

@ -56,8 +56,7 @@ gem 'activerecord-import'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'rack-timeout'
gem 'webpacker', '~> 5.x'
gem 'webpacker', '~> 5.4.0'
# metrics on heroku
gem 'barnes'
@ -122,6 +121,11 @@ gem 'hairtrigger'
gem 'procore-sift'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
end
group :development do
gem 'annotate'
gem 'bullet'

View file

@ -247,6 +247,7 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-protobuf (3.17.3)
google-protobuf (3.17.3-universal-darwin)
google-protobuf (3.17.3-x86_64-linux)
googleapis-common-protos (1.3.11)
@ -264,6 +265,9 @@ GEM
signet (~> 0.14)
groupdate (5.2.2)
activesupport (>= 5)
grpc (1.38.0)
google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0)
grpc (1.38.0-universal-darwin)
google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0)
@ -346,6 +350,7 @@ GEM
mime-types-data (3.2021.0704)
mini_magick (4.11.0)
mini_mime (1.1.1)
mini_portile2 (2.5.3)
minitest (5.14.4)
mock_redis (0.28.0)
ruby2_keywords
@ -360,6 +365,9 @@ GEM
netrc (0.11.0)
newrelic_rpm (7.2.0)
nio4r (2.5.8)
nokogiri (1.11.7)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
nokogiri (1.11.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.11.7-x86_64-darwin)
@ -617,6 +625,7 @@ GEM
PLATFORMS
arm64-darwin-20
ruby
x86_64-darwin-18
x86_64-darwin-20
x86_64-darwin-21
@ -714,7 +723,7 @@ DEPENDENCIES
valid_email2
web-console
webmock
webpacker (~> 5.x)
webpacker (~> 5.4.0)
webpush
wisper (= 2.0.0)

View file

@ -148,6 +148,14 @@ class Messages::Facebook::MessageBuilder
}
end
def process_contact_params_result(result)
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
}
end
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
@ -155,14 +163,15 @@ class Messages::Facebook::MessageBuilder
rescue Koala::Facebook::AuthenticationError
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
result = {}
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
# We don't need to capture this error as we don't care about contact params in case of echo messages
Sentry.capture_exception(e) unless @outgoing_echo
rescue StandardError => e
result = {}
Sentry.capture_exception(e)
end
{
name: "#{result['first_name'] || 'John'} #{result['last_name'] || 'Doe'}",
account_id: @inbox.account_id,
remote_avatar_url: result['profile_pic'] || ''
}
process_contact_params_result(result)
end
end

View file

@ -41,19 +41,25 @@ class V2::ReportBuilder
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.where(id: params[:id]).first
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.where(id: params[:id]).first
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.where(id: params[:id]).first
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def conversations_count

View file

@ -10,7 +10,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search]
before_action :fetch_contact, only: [:show, :update, :contactable_inboxes]
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes]
before_action :set_include_contact_inboxes, only: [:index, :search]
def index
@ -30,10 +30,13 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def import
render json: { error: I18n.t('errors.contacts.import.failed') }, status: :unprocessable_entity and return if params[:import_file].blank?
ActiveRecord::Base.transaction do
import = Current.account.data_imports.create!(data_type: 'contacts')
import.import_file.attach(params[:import_file])
end
head :ok
end
@ -70,6 +73,18 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
}, status: :unprocessable_entity
end
def destroy
if ::OnlineStatusTracker.get_presence(
@contact.account.id, 'Contact', @contact.id
)
return render_error({ message: I18n.t('contacts.online.delete', contact_name: @contact.name.capitalize) },
:unprocessable_entity)
end
@contact.destroy!
head :ok
end
private
# TODO: Move this to a finder class
@ -134,4 +149,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def fetch_contact
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def render_error(error, error_status)
render json: error, status: error_status
end
end

View file

@ -69,6 +69,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def update_last_seen
@conversation.agent_last_seen_at = DateTime.now.utc
@conversation.assignee_last_seen_at = DateTime.now.utc if assignee?
@conversation.save!
end
def custom_attributes
@conversation.custom_attributes = params.permit(custom_attributes: {})[:custom_attributes]
@conversation.save!
end
@ -112,6 +118,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation_params
additional_attributes = params[:additional_attributes]&.permit! || {}
custom_attributes = params[:custom_attributes]&.permit! || {}
status = params[:status].present? ? { status: params[:status] } : {}
# TODO: temporary fallback for the old bot status in conversation, we will remove after couple of releases
@ -122,6 +129,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes,
custom_attributes: custom_attributes,
snoozed_until: params[:snoozed_until]
}.merge(status)
end
@ -129,4 +137,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation_finder
@conversation_finder ||= ConversationFinder.new(current_user, params)
end
def assignee?
@conversation.assignee_id? && current_user == @conversation.assignee
end
end

View file

@ -100,6 +100,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def update_channel_feature_flags
return unless @inbox.web_widget?
return unless permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel].key? :selected_feature_flags
@inbox.channel.selected_feature_flags = permitted_params(Channel::WebWidget::EDITABLE_ATTRS)[:channel][:selected_feature_flags]

View file

@ -29,6 +29,12 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render layout: false, template: 'api/v2/accounts/reports/labels.csv.erb', format: 'csv'
end
def teams
response.headers['Content-Type'] = 'text/csv'
response.headers['Content-Disposition'] = 'attachment; filename=teams_report.csv'
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
end
private
def check_authorization

View file

@ -3,10 +3,9 @@ class SuperAdmin::DashboardController < SuperAdmin::ApplicationController
def index
@data = Conversation.unscoped.group_by_day(:created_at, range: 30.days.ago..2.seconds.ago).count.to_a
@accounts_count = number_with_delimiter(Account.all.length)
@users_count = number_with_delimiter(User.all.length)
@inboxes_count = number_with_delimiter(Inbox.all.length)
@conversations_count = number_with_delimiter(Conversation.all.length)
@messages_count = number_with_delimiter(Message.all.length)
@accounts_count = number_with_delimiter(Account.count)
@users_count = number_with_delimiter(User.count)
@inboxes_count = number_with_delimiter(Inbox.count)
@conversations_count = number_with_delimiter(Conversation.count)
end
end

View file

@ -52,6 +52,14 @@ class ContactAPI extends ApiClient {
)}`;
return axios.get(requestURL);
}
importContacts(file) {
const formData = new FormData();
formData.append('import_file', file);
return axios.post(`${this.url}/import`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
}
export default new ContactAPI();

View file

@ -59,6 +59,18 @@ describe('#ContactsAPI', () => {
'/api/v1/contacts/search?include_contact_inboxes=false&page=1&sort=date&q=leads&labels[]=customer-support'
);
});
it('#importContacts', () => {
const file = 'file';
contactAPI.importContacts(file);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/contacts/import',
expect.any(FormData),
{
headers: { 'Content-Type': 'multipart/form-data' },
}
);
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,3 @@
.margin-right-small {
margin-right: var(--space-small);
}

View file

@ -71,7 +71,8 @@
@include padding($space-large);
}
form {
form,
.modal-content {
@include padding($space-large);
align-self: center;

View file

@ -194,7 +194,7 @@ export default {
});
},
methods: {
handleKeyEvents(e) {
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
'div.conversations-list div.conversation'
);
@ -205,7 +205,19 @@ export default {
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
},
handleKeyEvents(e) {
if (hasPressedAltAndJKey(e)) {
const {
allConversations,
activeConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
@ -214,6 +226,11 @@ export default {
}
}
if (hasPressedAltAndKKey(e)) {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {

View file

@ -176,7 +176,9 @@ export default {
'.conversations-list .conversation'
);
if (hasPressedAltAndMKey(e)) {
this.$refs.arrowDownButton.$el.click();
if (this.$refs.arrowDownButton) {
this.$refs.arrowDownButton.$el.click();
}
}
if (hasPressedAltAndEKey(e)) {
const activeConversation = document.querySelector(

View file

@ -95,10 +95,15 @@ import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal';
import {
hasPressedAltAndCKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
hasPressedAltAndVKey,
hasPressedCommandAndForwardSlash,
isEscape,
} from 'shared/helpers/KeyboardHelpers';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import router from '../../routes';
export default {
components: {
@ -276,6 +281,27 @@ export default {
if (isEscape(e)) {
this.closeKeyShortcutModal();
}
if (hasPressedAltAndCKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('home')) {
router.push({ name: 'home' });
}
} else if (hasPressedAltAndVKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('contacts_dashboard')) {
router.push({ name: 'contacts_dashboard' });
}
} else if (hasPressedAltAndRKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('settings_account_reports')) {
router.push({ name: 'settings_account_reports' });
}
} else if (hasPressedAltAndSKey(e)) {
if (!this.isCurrentRouteSameAsNavigation('agent_list')) {
router.push({ name: 'agent_list' });
}
}
},
isCurrentRouteSameAsNavigation(routeName) {
return router.currentRoute && router.currentRoute.name === routeName;
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();

View file

@ -33,7 +33,7 @@
<a href="#" :class="computedChildClass(child)">
<div class="wrap">
<i
v-if="computedInboxClass(child)"
v-if="menuItem.key === 'inbox'"
class="inbox-icon"
:class="computedInboxClass(child)"
/>
@ -59,17 +59,10 @@
import { mapGetters } from 'vuex';
import router from '../../routes';
import {
hasPressedAltAndCKey,
hasPressedAltAndVKey,
hasPressedAltAndRKey,
hasPressedAltAndSKey,
} from 'shared/helpers/KeyboardHelpers';
import adminMixin from '../../mixins/isAdmin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
mixins: [adminMixin, eventListenerMixins],
mixins: [adminMixin],
props: {
menuItem: {
type: Object,
@ -124,20 +117,6 @@ export default {
}
}
},
handleKeyEvents(e) {
if (hasPressedAltAndCKey(e)) {
router.push({ name: 'home' });
}
if (hasPressedAltAndVKey(e)) {
router.push({ name: 'contacts_dashboard' });
}
if (hasPressedAltAndRKey(e)) {
router.push({ name: 'settings_account_reports' });
}
if (hasPressedAltAndSKey(e)) {
router.push({ name: 'settings_home' });
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},

View file

@ -22,19 +22,33 @@
src="~dashboard/assets/images/fb-badge.png"
/>
<img
v-if="badge === 'Channel::TwitterProfile'"
v-if="badge === 'twitter-tweet'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/twitter-badge.png"
/>
<img
v-if="badge === 'Channel::TwilioSms'"
v-if="badge === 'twitter-chat'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/twitter-chat-badge.png"
/>
<img
v-if="badge === 'whatsapp'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/whatsapp.png"
/>
<img
v-if="badge === 'sms'"
id="badge"
class="source-badge"
:style="badgeStyle"
src="~dashboard/assets/images/channels/sms.png"
/>
<img
v-if="badge === 'Channel::Line'"
id="badge"

View file

@ -11,7 +11,7 @@
<Thumbnail
v-if="!hideThumbnail"
:src="currentContact.thumbnail"
:badge="chatMetadata.channel"
:badge="inboxBadge"
class="columns"
:username="currentContact.name"
:status="currentContact.availability_status"
@ -68,13 +68,14 @@ import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import inboxMixin from 'shared/mixins/inboxMixin';
export default {
components: {
Thumbnail,
},
mixins: [timeMixin, conversationMixin, messageFormatterMixin],
mixins: [inboxMixin, timeMixin, conversationMixin, messageFormatterMixin],
props: {
activeLabel: {
type: String,
@ -167,14 +168,14 @@ export default {
return this.getPlainText(subject || this.lastMessageInChat.content);
},
chatInbox() {
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.chatInbox;
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
@ -187,11 +188,10 @@ export default {
);
},
inboxName() {
const stateInbox = this.chatInbox;
const stateInbox = this.inbox;
return stateInbox.name || '';
},
},
methods: {
cardClick(chat) {
const { activeInbox } = this;

View file

@ -4,7 +4,7 @@
<Thumbnail
:src="currentContact.thumbnail"
size="40px"
:badge="chatMetadata.channel"
:badge="inboxBadge"
:username="currentContact.name"
:status="currentContact.availability_status"
/>
@ -42,6 +42,7 @@ import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import agentMixin from '../../../mixins/agentMixin.js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
export default {
@ -49,7 +50,7 @@ export default {
MoreActions,
Thumbnail,
},
mixins: [agentMixin, eventListenerMixins],
mixins: [inboxMixin, agentMixin, eventListenerMixins],
props: {
chat: {
type: Object,
@ -78,6 +79,12 @@ export default {
return this.chat.meta;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id

View file

@ -76,7 +76,6 @@
import { mapGetters } from 'vuex';
import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import EmojiInput from 'shared/components/emoji/EmojiInput';
import CannedResponse from './CannedResponse';
@ -108,13 +107,7 @@ export default {
ReplyBottomPanel,
WootMessageEditor,
},
mixins: [
clickaway,
inboxMixin,
uiSettingsMixin,
alertMixin,
eventListenerMixins,
],
mixins: [clickaway, inboxMixin, uiSettingsMixin, alertMixin],
props: {
selectedTweet: {
type: [Object, String],
@ -304,6 +297,15 @@ export default {
}
},
},
mounted() {
// Donot use the keyboard listener mixin here as the events here are supposed to be
// working even if input/textarea is focussed.
document.addEventListener('keydown', this.handleKeyEvents);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
},
methods: {
toggleUserMention(currentMentionState) {
this.hasUserMention = currentMentionState;
@ -345,7 +347,10 @@ export default {
await this.$store.dispatch('sendMessage', messagePayload);
this.$emit('scrollToMessage');
} catch (error) {
// Error
const errorMessage =
error?.response?.data?.error ||
this.$t('CONVERSATION.MESSAGE_ERROR');
this.showAlert(errorMessage);
}
this.hideEmojiPicker();
}

View file

@ -19,6 +19,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'conversation.typing_off': this.onTypingOff,
'conversation.contact_changed': this.onConversationContactChange,
'presence.update': this.onPresenceUpdate,
'contact.deleted': this.onContactDelete,
};
}
@ -115,6 +116,14 @@ class ActionCableConnector extends BaseActionCableConnector {
fetchConversationStats = () => {
bus.$emit('fetch_conversation_stats');
};
onContactDelete = data => {
this.app.$store.dispatch(
'contacts/deleteContactThroughConversations',
data.id
);
this.fetchConversationStats();
};
}
export default {

View file

@ -22,7 +22,10 @@ export const getInboxClassByType = (type, phoneNumber) => {
case INBOX_TYPES.EMAIL:
return 'ion-ios-email';
case INBOX_TYPES.TELEGRAM:
return 'ion-ios-navigate';
default:
return '';
return 'ion-ios-chatbubble';
}
};

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Vyberte časové pásmo",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "Vi er ikke tilgængelige i øjeblikket. Skriv en besked og vi svarer, når vi er tilbage.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -54,6 +54,35 @@
"TITLE": "Create new contact",
"DESC": "Add basic information details about the contact."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "Cancel"
},
"SUCCESS_MESSAGE": "Contacts saved successfully",
"ERROR_MESSAGE": "There was an error, please try again"
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
"DESC": "Delete contact details",
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete ",
"PLACE_HOLDER": "Please type {contactName} to confirm",
"YES": "Yes, Delete ",
"NO": "No, Keep "
},
"API": {
"SUCCESS_MESSAGE": "Contact deleted successfully",
"ERROR_MESSAGE": "Could not delete contact. Please try again later."
}
},
"CONTACT_FORM": {
"FORM": {
"SUBMIT": "Submit",
@ -239,4 +268,4 @@
"ERROR_MESSAGE": "Could not merge contcts, try again!"
}
}
}
}

View file

@ -84,6 +84,7 @@
"CHANGE_AGENT": "Conversation Assignee changed",
"CHANGE_TEAM": "Conversation team changed",
"FILE_SIZE_LIMIT": "File exceeds the {MAXIMUM_FILE_UPLOAD_SIZE} attachment limit",
"MESSAGE_ERROR": "Unable to send this message, please try again later",
"SENT_BY": "Sent by:",
"ASSIGNMENT": {
"SELECT_AGENT": "Select Agent",

View file

@ -56,6 +56,11 @@
"CHANNEL_AVATAR": {
"LABEL": "Channel Avatar"
},
"CHANNEL_WEBHOOK_URL": {
"LABEL": "Webhook URL",
"PLACEHOLDER": "Enter your Webhook URL",
"ERROR": "Please enter a valid URL"
},
"CHANNEL_DOMAIN": {
"LABEL": "Website Domain",
"PLACEHOLDER": "Enter your website domain (eg: acme.com)"
@ -127,11 +132,11 @@
"ERROR_MESSAGE": "We were not able to authenticate Twilio credentials, please try again"
}
},
"SMS": {
"SMS": {
"TITLE": "SMS Channel via Twilio",
"DESC": "Start supporting your customers via SMS with Twilio integration."
},
"WHATSAPP": {
"WHATSAPP": {
"TITLE": "Whatsapp Channel via Twilio",
"DESC": "Start supporting your customers via Whatsapp with Twilio integration."
},
@ -195,6 +200,10 @@
"SUBMIT_BUTTON": "Create LINE Channel",
"API": {
"ERROR_MESSAGE": "We were not able to save the LINE channel"
},
"API_CALLBACK": {
"TITLE": "Callback URL",
"SUBTITLE": "You have to configure the webhook URL in LINE application with the URL mentioned here."
}
},
"TELEGRAM_CHANNEL": {
@ -350,7 +359,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -13,18 +13,18 @@
"STATUS_TABS": [
{
"NAME": "Apri",
"KEY": "contaaperture"
"KEY": "openCount"
},
{
"NAME": "Risolti",
"KEY": "Conteggio"
"KEY": "allConvCount"
}
],
"ASSIGNEE_TYPE_TABS": [
{
"NAME": "Miniera",
"KEY": "Io",
"COUNT_KEY": "contaMinore"
"COUNT_KEY": "mineCount"
},
{
"NAME": "Non assegnato",
@ -40,11 +40,11 @@
"CHAT_STATUS_ITEMS": [
{
"TEXT": "Apri",
"VALUE": "Aperto"
"VALUE": "open"
},
{
"TEXT": "Risolti",
"VALUE": "risolto"
"VALUE": "resolved"
},
{
"TEXT": "Pending",

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -350,7 +350,7 @@
"TIMEZONE_LABEL": "Select timezone",
"UPDATE": "Update business hours settings",
"TOGGLE_AVAILABILITY": "Enable business availability for this inbox",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for vistors",
"UNAVAILABLE_MESSAGE_LABEL": "Unavailable message for visitors",
"UNAVAILABLE_MESSAGE_DEFAULT": "We are unavailable at the moment. Leave a message we will respond once we are back.",
"TOGGLE_HELP": "Enabling business availability will show the available hours on live chat widget even if all the agents are offline. Outside available hours vistors can be warned with a message and a pre-chat form.",
"DAY": {

View file

@ -3,7 +3,7 @@
<span class="close-button" @click="onClose">
<i class="ion-android-close close-icon" />
</span>
<contact-info show-new-message :contact="contact" />
<contact-info show-new-message :contact="contact" @panel-close="onClose" />
<accordion-item
:title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CUSTOM_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')"

View file

@ -24,12 +24,12 @@ export default {
computed: {
savedLabels() {
const result = this.$store.getters['contactLabels/getContactLabels'](
this.contactId
const availableContactLabels = this.$store.getters[
'contactLabels/getContactLabels'
](this.contactId);
return this.allLabels.filter(({ title }) =>
availableContactLabels.includes(title)
);
return result.map(value => {
return this.allLabels.find(label => label.title === value);
});
},
...mapGetters({

View file

@ -7,6 +7,7 @@
this-selected-contact-id=""
:on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate"
:on-toggle-import="onToggleImport"
:header-title="label"
/>
<contacts-table
@ -30,6 +31,9 @@
:on-close="closeContactInfoPanel"
/>
<create-contact :show="showCreateModal" @cancel="onToggleCreate" />
<woot-modal :show.sync="showImportModal" :on-close="onToggleImport">
<import-contacts v-if="showImportModal" :on-close="onToggleImport" />
</woot-modal>
</div>
</template>
@ -41,6 +45,7 @@ import ContactsTable from './ContactsTable';
import ContactInfoPanel from './ContactInfoPanel';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact';
import TableFooter from 'dashboard/components/widgets/TableFooter';
import ImportContacts from './ImportContacts.vue';
const DEFAULT_PAGE = 1;
@ -51,6 +56,7 @@ export default {
TableFooter,
ContactInfoPanel,
CreateContact,
ImportContacts,
},
props: {
label: { type: String, default: '' },
@ -59,6 +65,7 @@ export default {
return {
searchQuery: '',
showCreateModal: false,
showImportModal: false,
selectedContactId: '',
sortConfig: { name: 'asc' },
};
@ -168,6 +175,9 @@ export default {
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
},
onToggleImport() {
this.showImportModal = !this.showImportModal;
},
onSortChange(params) {
this.sortConfig = params;
this.fetchContacts(this.meta.currentPage);

View file

@ -29,11 +29,20 @@
<woot-button
color-scheme="success"
icon="ion-android-add-circle"
@click="onToggleCreate"
class="margin-right-small"
data-testid="create-new-contact"
@click="onToggleCreate"
>
{{ $t('CREATE_CONTACT.BUTTON_LABEL') }}
</woot-button>
<woot-button
color-scheme="info"
icon="ion-android-upload"
@click="onToggleImport"
>
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</header>
@ -41,7 +50,6 @@
<script>
export default {
components: {},
props: {
headerTitle: {
type: String,
@ -63,10 +71,15 @@ export default {
type: Function,
default: () => {},
},
onToggleImport: {
type: Function,
default: () => {},
},
},
data() {
return {
showCreateModal: false,
showImportModal: false,
};
},
computed: {
@ -78,6 +91,7 @@ export default {
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/_utility-helpers.scss';
.page-title {
margin: 0;
}

View file

@ -0,0 +1,92 @@
<template>
<modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header :header-title="$t('IMPORT_CONTACTS.TITLE')">
<p>
{{ $t('IMPORT_CONTACTS.DESC') }}
<a :href="csvUrl" download="import-contacts-sample">{{
$t('IMPORT_CONTACTS.DOWNLOAD_LABEL')
}}</a>
</p>
</woot-modal-header>
<div class="row modal-content">
<div class="medium-12 columns">
<label>
<span>{{ $t('IMPORT_CONTACTS.FORM.LABEL') }}</span>
<input
id="file"
ref="file"
type="file"
accept="text/csv"
@change="handleFileUpload"
/>
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-button
:disabled="uiFlags.isCreating || !file"
:loading="uiFlags.isCreating"
@click="uploadFile"
>
{{ $t('IMPORT_CONTACTS.FORM.SUBMIT') }}
</woot-button>
<button class="button clear" @click.prevent="onClose">
{{ $t('IMPORT_CONTACTS.FORM.CANCEL') }}
</button>
</div>
</div>
</div>
</div>
</modal>
</template>
<script>
import Modal from '../../../../components/Modal';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Modal,
},
mixins: [alertMixin],
props: {
onClose: {
type: Function,
default: () => {},
},
},
data() {
return {
show: true,
file: '',
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
csvUrl() {
return '/downloads/import-contacts-sample.csv';
},
},
methods: {
async uploadFile() {
try {
if (!this.file) return;
await this.$store.dispatch('contacts/import', this.file);
this.onClose();
this.showAlert(this.$t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
error.message || this.$t('IMPORT_CONTACTS.ERROR_MESSAGE')
);
}
},
handleFileUpload() {
this.file = this.$refs.file.files[0];
},
},
};
</script>

View file

@ -48,30 +48,59 @@
/>
</div>
</div>
<woot-button
v-if="!showNewMessage"
class="edit-contact"
variant="link"
size="small"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
<div v-else class="contact-actions">
<woot-button
class="new-message"
size="small expanded"
@click="toggleConversationModal"
>
{{ $t('CONTACT_PANEL.NEW_MESSAGE') }}
</woot-button>
<woot-button
variant="smooth"
size="small expanded"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
<div v-if="!showNewMessage">
<div>
<woot-button
class="edit-contact"
variant="link"
size="small"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
<div v-if="isAdmin">
<woot-button
class="delete-contact"
variant="link"
size="small"
color-scheme="alert"
@click="toggleDeleteModal"
:disabled="uiFlags.isDeleting"
>
{{ $t('DELETE_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
<div v-else>
<div class="contact-actions">
<woot-button
v-tooltip="$t('CONTACT_PANEL.NEW_MESSAGE')"
class="new-message"
icon="ion-chatboxes"
size="small expanded"
@click="toggleConversationModal"
/>
<woot-button
v-tooltip="$t('EDIT_CONTACT.BUTTON_LABEL')"
class="edit-contact"
icon="ion-edit"
variant="smooth"
size="small expanded"
@click="toggleEditModal"
/>
<woot-button
v-if="isAdmin"
v-tooltip="$t('DELETE_CONTACT.BUTTON_LABEL')"
class="delete-contact"
icon="ion-trash-a"
variant="hollow"
size="small expanded"
color-scheme="alert"
@click="toggleDeleteModal"
:disabled="uiFlags.isDeleting"
/>
</div>
</div>
<edit-contact
v-if="showEditModal"
@ -80,11 +109,24 @@
@cancel="toggleEditModal"
/>
<new-conversation
v-if="contact.id"
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
</div>
<woot-confirm-delete-modal
v-if="showDeleteModal"
:show.sync="showDeleteModal"
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="confirmDeleteMessage"
:confirm-text="deleteConfirmText"
:reject-text="deleteRejectText"
:confirm-value="contact.name"
:confirm-place-holder-text="confirmPlaceHolderText"
@on-confirm="confirmDeletion"
@on-close="closeDelete"
/>
</div>
</template>
<script>
@ -93,6 +135,9 @@ import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons';
import EditContact from './EditContact';
import NewConversation from './NewConversation';
import alertMixin from 'shared/mixins/alertMixin';
import adminMixin from '../../../../mixins/isAdmin';
import { mapGetters } from 'vuex';
export default {
components: {
@ -102,6 +147,7 @@ export default {
SocialIcons,
NewConversation,
},
mixins: [alertMixin, adminMixin],
props: {
contact: {
type: Object,
@ -120,9 +166,11 @@ export default {
return {
showEditModal: false,
showConversationModal: false,
showDeleteModal: false,
};
},
computed: {
...mapGetters({ uiFlags: 'contacts/getUIFlags' }),
additionalAttributes() {
return this.contact.additional_attributes || {};
},
@ -134,6 +182,23 @@ export default {
return { twitter: twitterScreenName, ...(socialProfiles || {}) };
},
// Delete Modal
deleteConfirmText() {
return `${this.$t('DELETE_CONTACT.CONFIRM.YES')} ${this.contact.name}`;
},
deleteRejectText() {
return `${this.$t('DELETE_CONTACT.CONFIRM.NO')} ${this.contact.name}`;
},
confirmDeleteMessage() {
return `${this.$t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${
this.contact.name
} ?`;
},
confirmPlaceHolderText() {
return `${this.$t('DELETE_CONTACT.CONFIRM.PLACE_HOLDER', {
contactName: this.contact.name,
})}`;
},
},
methods: {
toggleEditModal() {
@ -142,6 +207,31 @@ export default {
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
confirmDeletion() {
this.deleteContact(this.contact);
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
this.showConversationModal = false;
this.showEditModal = false;
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
this.$emit('panel-close');
this.showAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlert(
error.message
? error.message
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
}
},
},
};
</script>
@ -179,17 +269,32 @@ export default {
.contact-actions {
margin-top: var(--space-small);
}
.button.edit-contact {
.edit-contact {
margin-left: var(--space-medium);
}
.button.new-message {
margin-right: var(--space-small);
.delete-contact {
margin-left: var(--space-medium);
}
.contact-actions {
display: flex;
align-items: center;
width: 100%;
.new-message {
font-size: var(--font-size-medium);
}
.edit-contact {
margin-left: var(--space-small);
font-size: var(--font-size-medium);
}
.delete-contact {
margin-left: var(--space-small);
font-size: var(--font-size-medium);
}
}
</style>

View file

@ -237,7 +237,7 @@ export default {
if (this.isOngoingType) {
return this.$store.getters['inboxes/getWebsiteInboxes'];
}
return this.$store.getters['inboxes/getTwilioInboxes'];
return this.$store.getters['inboxes/getTwilioSMSInboxes'];
},
sendersAndBotList() {
return [

View file

@ -161,7 +161,7 @@ export default {
if (this.isOngoingType) {
return this.$store.getters['inboxes/getWebsiteInboxes'];
}
return this.$store.getters['inboxes/getTwilioInboxes'];
return this.$store.getters['inboxes/getTwilioSMSInboxes'];
},
pageTitle() {
return `${this.$t('CAMPAIGN.EDIT.TITLE')} - ${

View file

@ -88,7 +88,7 @@ export default {
const selectedAgents = this.selectedAgents.map(x => x.id);
try {
await InboxMembersAPI.create({ inboxId, agentList: selectedAgents });
await InboxMembersAPI.update({ inboxId, agentList: selectedAgents });
router.replace({
name: 'settings_inbox_finish',
params: {

View file

@ -17,7 +17,7 @@
<woot-code
v-if="isATwilioInbox"
lang="html"
:script="currentInbox.webhook_url"
:script="currentInbox.callback_webhook_url"
>
</woot-code>
</div>
@ -25,7 +25,7 @@
<woot-code
v-if="isALineInbox"
lang="html"
:script="currentInbox.webhook_url"
:script="currentInbox.callback_webhook_url"
>
</woot-code>
</div>
@ -93,6 +93,12 @@ export default {
)}`;
}
if (this.isALineInbox) {
return `${this.$t('INBOX_MGMT.FINISH.MESSAGE')}. ${this.$t(
'INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE'
)}`;
}
if (this.isAEmailInbox) {
return this.$t('INBOX_MGMT.ADD.EMAIL_CHANNEL.FINISH_MESSAGE');
}

View file

@ -51,6 +51,9 @@
<span v-if="item.channel_type === 'Channel::Telegram'">
Telegram
</span>
<span v-if="item.channel_type === 'Channel::Line'">
Line
</span>
<span v-if="item.channel_type === 'Channel::Api'">
{{ globalConfig.apiChannelName || 'API' }}
</span>

View file

@ -22,7 +22,7 @@
<woot-avatar-uploader
:label="$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_AVATAR.LABEL')"
:src="avatarUrl"
deleteAvatar
delete-avatar
@change="handleImageUpload"
@onAvatarDelete="handleAvatarDelete"
/>
@ -32,6 +32,24 @@
:label="inboxNameLabel"
:placeholder="inboxNamePlaceHolder"
/>
<woot-input
v-if="isAPIInbox"
v-model.trim="webhookUrl"
class="medium-9 columns"
:class="{ error: $v.webhookUrl.$error }"
:label="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.LABEL')
"
:placeholder="
$t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.PLACEHOLDER')
"
:error="
$v.webhookUrl.$error
? $t('INBOX_MGMT.ADD.WEBSITE_CHANNEL.CHANNEL_WEBHOOK_URL.ERROR')
: ''
"
@blur="$v.webhookUrl.$touch"
/>
<woot-input
v-if="isAWebWidgetInbox"
v-model.trim="channelWebsiteUrl"
@ -212,6 +230,16 @@
</div>
<woot-submit-button
v-if="isAPIInbox"
type="submit"
:disabled="$v.webhookUrl.$invalid"
:button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
@click="updateInbox"
/>
<woot-submit-button
v-else
type="submit"
:button-text="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:loading="uiFlags.isUpdatingInbox"
@click="updateInbox"
@ -259,7 +287,21 @@
:title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.TWILIO.API_CALLBACK.SUBTITLE')"
>
<woot-code :script="twilioCallbackURL" lang="html"></woot-code>
<woot-code
:script="inbox.callback_webhook_url"
lang="html"
></woot-code>
</settings-section>
</div>
<div v-else-if="isALineChannel" class="settings--content">
<settings-section
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
:sub-title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.SUBTITLE')"
>
<woot-code
:script="inbox.callback_webhook_url"
lang="html"
></woot-code>
</settings-section>
</div>
<div v-else-if="isAWebWidgetInbox">
@ -310,6 +352,8 @@
<script>
import { mapGetters } from 'vuex';
import { createMessengerScript } from 'dashboard/helper/scriptGenerator';
import { required } from 'vuelidate/lib/validators';
import { shouldBeUrl } from 'shared/helpers/Validators';
import configMixin from 'shared/mixins/configMixin';
import alertMixin from 'shared/mixins/alertMixin';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner';
@ -343,6 +387,7 @@ export default {
csatSurveyEnabled: false,
selectedInboxName: '',
channelWebsiteUrl: '',
webhookUrl: '',
channelWelcomeTitle: '',
channelWelcomeTagline: '',
selectedFeatureFlags: [],
@ -378,6 +423,10 @@ export default {
key: 'collaborators',
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
},
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
];
if (this.isAWebWidgetInbox) {
@ -387,10 +436,6 @@ export default {
key: 'preChatForm',
name: this.$t('INBOX_MGMT.TABS.PRE_CHAT_FORM'),
},
{
key: 'businesshours',
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
},
{
key: 'configuration',
name: this.$t('INBOX_MGMT.TABS.CONFIGURATION'),
@ -398,7 +443,12 @@ export default {
];
}
if (this.isATwilioChannel || this.isAPIInbox || this.isAnEmailChannel) {
if (
this.isATwilioChannel ||
this.isALineChannel ||
this.isAPIInbox ||
this.isAnEmailChannel
) {
return [
...visibleToAllChannelTabs,
{
@ -484,6 +534,7 @@ export default {
this.fetchAttachedAgents();
this.avatarUrl = this.inbox.avatar_url;
this.selectedInboxName = this.inbox.name;
this.webhookUrl = this.inbox.webhook_url;
this.greetingEnabled = this.inbox.greeting_enabled || false;
this.greetingMessage = this.inbox.greeting_message || '';
this.autoAssignment = this.inbox.enable_auto_assignment;
@ -536,6 +587,7 @@ export default {
channel: {
widget_color: this.inbox.widget_color,
website_url: this.channelWebsiteUrl,
webhook_url: this.webhookUrl,
welcome_title: this.channelWelcomeTitle || '',
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
@ -574,6 +626,10 @@ export default {
},
},
validations: {
webhookUrl: {
required,
shouldBeUrl,
},
selectedAgents: {
isEmpty() {
return !!this.selectedAgents.length;

View file

@ -82,6 +82,9 @@ export const mutations = {
const conversations = $state.records[id] || [];
Vue.set($state.records, id, [...conversations, data]);
},
[types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => {
Vue.delete($state.records, id);
},
};
export default {

View file

@ -82,6 +82,32 @@ export const actions = {
}
}
},
import: async ({ commit }, file) => {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
try {
await ContactAPI.importContacts(file);
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
}
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
try {
await ContactAPI.delete(id);
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
throw new Error(error);
}
}
},
fetchContactableInbox: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
@ -110,4 +136,12 @@ export const actions = {
setContact({ commit }, data) {
commit(types.SET_CONTACT_ITEM, data);
},
deleteContactThroughConversations: ({ commit }, id) => {
commit(types.DELETE_CONTACT, id);
commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true });
commit(`contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, id, {
root: true,
});
},
};

View file

@ -13,6 +13,7 @@ const state = {
isFetchingItem: false,
isFetchingInboxes: false,
isUpdating: false,
isDeleting: false,
},
sortOrder: [],
};

View file

@ -46,6 +46,12 @@ export const mutations = {
Vue.set($state.records, data.id, data);
},
[types.DELETE_CONTACT]: ($state, id) => {
const index = $state.sortOrder.findIndex(item => item === id);
Vue.delete($state.sortOrder, index);
Vue.delete($state.records, id);
},
[types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
Object.values($state.records).forEach(element => {
const availabilityStatus = data[element.id];

View file

@ -155,6 +155,7 @@ const actions = {
},
sendMessage: async ({ commit }, data) => {
// eslint-disable-next-line no-useless-catch
try {
const pendingMessage = createPendingMessage(data);
commit(types.default.ADD_MESSAGE, pendingMessage);
@ -164,7 +165,7 @@ const actions = {
status: MESSAGE_STATUS.SENT,
});
} catch (error) {
// Handle error
throw error;
}
},

View file

@ -177,6 +177,13 @@ export const mutations = {
Vue.set(chat, 'can_reply', canReply);
}
},
[types.default.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) {
const chats = _state.allConversations.filter(
c => c.meta.sender.id !== contactId
);
Vue.set(_state, 'allConversations', chats);
},
};
export default {

View file

@ -73,6 +73,11 @@ export const getters = {
item => item.channel_type === INBOX_TYPES.TWILIO
);
},
getTwilioSMSInboxes($state) {
return $state.records.filter(
item => item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms'
);
},
};
export const actions = {

View file

@ -139,6 +139,27 @@ describe('#actions', () => {
});
});
describe('#delete', () => {
it('sends correct mutations if API is success', async () => {
axios.delete.mockResolvedValue();
await actions.delete({ commit }, contactList[0].id);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isDeleting: true }],
[types.SET_CONTACT_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete({ commit }, contactList[0].id)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isDeleting: true }],
[types.SET_CONTACT_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('#setContact', () => {
it('returns correct mutations', () => {
const data = { id: 1, name: 'john doe', availability_status: 'online' };
@ -146,4 +167,19 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([[types.SET_CONTACT_ITEM, data]]);
});
});
describe('#deleteContactThroughConversations', () => {
it('returns correct mutations', () => {
actions.deleteContactThroughConversations({ commit }, contactList[0].id);
expect(commit.mock.calls).toEqual([
[types.DELETE_CONTACT, contactList[0].id],
[types.CLEAR_CONTACT_CONVERSATIONS, contactList[0].id, { root: true }],
[
`contactConversations/${types.DELETE_CONTACT_CONVERSATION}`,
contactList[0].id,
{ root: true },
],
]);
});
});
});

View file

@ -49,10 +49,10 @@ export default [
name: 'Test Widget 5',
channel_type: 'Channel::TwilioSms',
avatar_url: null,
medium: 'sms',
page_id: null,
widget_color: '#68BC00',
website_token: 'randomid125',
enable_auto_assignment: true,
},
];

View file

@ -19,6 +19,11 @@ describe('#getters', () => {
expect(getters.getTwilioInboxes(state).length).toEqual(1);
});
it('getTwilioSMSInboxes', () => {
const state = { records: inboxList };
expect(getters.getTwilioSMSInboxes(state).length).toEqual(1);
});
it('getInbox', () => {
const state = {
records: inboxList,

View file

@ -18,6 +18,7 @@ export default {
CHANGE_CHAT_STATUS_FILTER: 'CHANGE_CHAT_STATUS_FILTER',
UPDATE_ASSIGNEE: 'UPDATE_ASSIGNEE',
UPDATE_CONVERSATION_CONTACT: 'UPDATE_CONVERSATION_CONTACT',
CLEAR_CONTACT_CONVERSATIONS: 'CLEAR_CONTACT_CONVERSATIONS',
SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW',
CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW',
@ -101,6 +102,7 @@ export default {
SET_CONTACTS: 'SET_CONTACTS',
CLEAR_CONTACTS: 'CLEAR_CONTACTS',
EDIT_CONTACT: 'EDIT_CONTACT',
DELETE_CONTACT: 'DELETE_CONTACT',
UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE',
// Notifications
@ -119,6 +121,7 @@ export default {
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
SET_CONTACT_CONVERSATIONS: 'SET_CONTACT_CONVERSATIONS',
ADD_CONTACT_CONVERSATION: 'ADD_CONTACT_CONVERSATION',
DELETE_CONTACT_CONVERSATION: 'DELETE_CONTACT_CONVERSATION',
// Contact Label
SET_CONTACT_LABELS_UI_FLAG: 'SET_CONTACT_LABELS_UI_FLAG',

View file

@ -40,8 +40,8 @@ const runSDK = ({ baseUrl, websiteToken }) => {
launcherTitle: chatwootSettings.launcherTitle || '',
showPopoutButton: chatwootSettings.showPopoutButton || false,
toggle() {
IFrameHelper.events.toggleBubble();
toggle(state) {
IFrameHelper.events.toggleBubble(state);
},
setUser(identifier, user) {

View file

@ -148,8 +148,15 @@ export const IFrameHelper = {
setBubbleText(window.$chatwoot.launcherTitle || message.label);
},
toggleBubble: () => {
onBubbleClick();
toggleBubble: state => {
let bubbleState = {};
if (state === 'open') {
bubbleState.toggleValue = true;
} else if (state === 'close') {
bubbleState.toggleValue = false;
}
onBubbleClick(bubbleState);
},
onBubbleToggle: isOpen => {

View file

@ -99,6 +99,7 @@ export const SDK_CSS = `.woot-widget-holder {
.woot--close::before, .woot--close::after {
background-color: #fff;
content: ' ';
display: inline;
height: 24px;
left: 32px;
position: absolute;
@ -149,7 +150,7 @@ export const SDK_CSS = `.woot-widget-holder {
max-height: 100vh;
padding: 0 8px;
}
.woot-widget-holder.has-unread-view iframe {
min-height: unset !important;
}
@ -157,7 +158,7 @@ export const SDK_CSS = `.woot-widget-holder {
.woot-widget-holder.has-unread-view.woot-elements--left {
left: 0;
}
.woot-widget-bubble.woot--close {
bottom: 60px;
opacity: 0;

View file

@ -41,10 +41,11 @@ export default {
@import '~widget/assets/scss/variables.scss';
.option {
border: 1px solid $color-woot;
border-radius: $space-jumbo;
border: 1px solid $color-woot;
float: left;
margin: $space-smaller;
max-width: 100%;
.option-button {
background: transparent;
@ -52,7 +53,11 @@ export default {
border: 0;
color: $color-woot;
cursor: pointer;
height: auto;
line-height: 1.5;
min-height: $space-two * 2;
text-align: left;
white-space: normal;
span {
display: inline-block;

View file

@ -16,7 +16,6 @@
:src="selectedItem.thumbnail"
size="24px"
:status="selectedItem.availability_status"
:badge="selectedItem.channel"
:username="selectedItem.name"
/>
<div class="selector-name-wrap">

View file

@ -1,2 +1,4 @@
export const isPhoneE164 = value => !!value.match(/^\+[1-9]\d{1,14}$/);
export const isPhoneE164OrEmpty = value => isPhoneE164(value) || value === '';
export const shouldBeUrl = (value = '') =>
value ? value.startsWith('http') : true;

View file

@ -0,0 +1,7 @@
import { shouldBeUrl } from '../Validators';
describe('#shouldBeUrl', () => {
it('should return correct url', () => {
expect(shouldBeUrl('http')).toEqual(true);
});
});

View file

@ -1,8 +1,28 @@
import { isEscape } from '../helpers/KeyboardHelpers';
export default {
mounted() {
document.addEventListener('keydown', this.handleKeyEvents);
document.addEventListener('keydown', this.onKeyDownHandler);
},
destroyed() {
document.removeEventListener('keydown', this.handleKeyEvents);
beforeDestroy() {
document.removeEventListener('keydown', this.onKeyDownHandler);
},
methods: {
onKeyDownHandler(e) {
const isEventFromAnInputBox =
e.target?.tagName === 'INPUT' || e.target?.tagName === 'TEXTAREA';
const isEventFromProseMirror = e.target?.className?.includes(
'ProseMirror'
);
if (isEventFromAnInputBox || isEventFromProseMirror) {
if (isEscape(e)) {
e.target.blur();
}
return;
}
this.handleKeyEvents(e);
},
},
};

View file

@ -5,6 +5,8 @@ export const INBOX_TYPES = {
TWILIO: 'Channel::TwilioSms',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
};
export default {
@ -27,16 +29,41 @@ export default {
isATwilioChannel() {
return this.channelType === INBOX_TYPES.TWILIO;
},
isALineChannel() {
return this.channelType === INBOX_TYPES.LINE;
},
isAnEmailChannel() {
return this.channelType === INBOX_TYPES.EMAIL;
},
isATwilioSMSChannel() {
const { phone_number: phoneNumber = '' } = this.inbox;
return this.isATwilioChannel && !phoneNumber.startsWith('whatsapp');
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'sms';
},
isATwilioWhatsappChannel() {
const { phone_number: phoneNumber = '' } = this.inbox;
return this.isATwilioChannel && phoneNumber.startsWith('whatsapp');
const { medium: medium = '' } = this.inbox;
return this.isATwilioChannel && medium === 'whatsapp';
},
isTwitterInboxTweet() {
return (
this.chat &&
this.chat.additional_attributes &&
this.chat.additional_attributes.type === 'tweet'
);
},
twilioBadge() {
return `${this.isATwilioSMSChannel ? 'sms' : 'whatsapp'}`;
},
twitterBadge() {
return `${this.isTwitterInboxTweet ? 'twitter-tweet' : 'twitter-chat'}`;
},
inboxBadge() {
if (this.isATwitterInbox) {
return this.twitterBadge;
}
if (this.isATwilioChannel) {
return this.twilioBadge;
}
return this.channelType;
},
},
};

View file

@ -70,7 +70,23 @@ describe('inboxMixin', () => {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
phone_number: '+91944444444',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(true);
});
it('isATwilioSMSChannel returns true if channel type is Twilio and medium is SMS', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
},
};
},
@ -80,7 +96,7 @@ describe('inboxMixin', () => {
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
});
it('isATwilioWhatsappChannel returns true if channel type is Twilio and phonenumber is a whatsapp number', () => {
it('isATwilioWhatsappChannel returns true if channel type is Twilio and medium is whatsapp', () => {
const Component = {
render() {},
mixins: [inboxMixin],
@ -88,7 +104,7 @@ describe('inboxMixin', () => {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
phone_number: 'whatsapp:+91944444444',
medium: 'whatsapp',
},
};
},
@ -111,4 +127,79 @@ describe('inboxMixin', () => {
const wrapper = shallowMount(Component);
expect(wrapper.vm.isAnEmailChannel).toBe(true);
});
it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
chat: {
channel_type: 'Channel::TwitterProfile',
additional_attributes: {
type: 'tweet',
},
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
});
it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::TwilioSms',
medium: 'sms',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
expect(wrapper.vm.twilioBadge).toBe('sms');
});
it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
chat: {
id: 1,
additional_attributes: {
type: 'tweet',
},
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
expect(wrapper.vm.twitterBadge).toBe('twitter-tweet');
});
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
const Component = {
render() {},
mixins: [inboxMixin],
data() {
return {
inbox: {
channel_type: 'Channel::Telegram',
},
};
},
};
const wrapper = shallowMount(Component);
expect(wrapper.vm.isATwilioChannel).toBe(false);
expect(wrapper.vm.isATwitterInbox).toBe(false);
expect(wrapper.vm.channelType).toBe('Channel::Telegram');
});
});

View file

@ -27,6 +27,7 @@ class ContactIpLookupJob < ApplicationJob
geocoder_result = Geocoder.search(ip).first
return unless geocoder_result
contact.additional_attributes ||= {}
contact.additional_attributes['city'] = geocoder_result.city
contact.additional_attributes['country'] = geocoder_result.country
contact.additional_attributes['country_code'] = geocoder_result.country_code
@ -34,7 +35,7 @@ class ContactIpLookupJob < ApplicationJob
end
def get_contact_ip(contact)
contact.additional_attributes['updated_at_ip'] || contact.additional_attributes['created_at_ip']
contact.additional_attributes&.dig('updated_at_ip') || contact.additional_attributes&.dig('created_at_ip')
end
def ensure_look_up_db

View file

@ -0,0 +1,11 @@
class Labels::UpdateJob < ApplicationJob
queue_as :default
def perform(new_label_title, old_label_title, account_id)
Labels::UpdateService.new(
new_label_title: new_label_title,
old_label_title: old_label_title,
account_id: account_id
).perform
end
end

View file

@ -111,6 +111,13 @@ class ActionCableListener < BaseListener
broadcast(account, tokens, CONTACT_MERGED, contact.push_event_data)
end
def contact_deleted(event)
contact, account = extract_contact_and_account(event)
tokens = user_tokens(account, account.agents)
broadcast(account, tokens, CONTACT_DELETED, contact.push_event_data)
end
private
def typing_event_listener_tokens(account, conversation, user)

View file

@ -22,7 +22,7 @@ class ApplicationMailbox < ActionMailbox::Base
proc do |inbound_mail_obj|
is_a_support_email = false
inbound_mail_obj.mail.to&.each do |email|
channel = Channel::Email.find_by('email = ? OR forward_to_email = ?', email, email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
if channel.present?
is_a_support_email = true
break

View file

@ -21,7 +21,7 @@ class SupportMailbox < ApplicationMailbox
def find_channel
mail.to.each do |email|
@channel = Channel::Email.find_by('email = ? OR forward_to_email = ?', email, email)
@channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
break if @channel.present?
end
raise 'Email channel/inbox not found' if @channel.nil?
@ -82,6 +82,6 @@ class SupportMailbox < ApplicationMailbox
end
def identify_contact_name
processed_mail.from.first.split('@').first
processed_mail.sender_name || processed_mail.from.first.split('@').first
end
end

View file

@ -25,6 +25,7 @@ class Attachment < ApplicationRecord
enum file_type: [:image, :audio, :video, :file, :location, :fallback]
def push_event_data
return unless file_type
return base_data.merge(location_metadata) if file_type.to_sym == :location
return base_data.merge(fallback_data) if file_type.to_sym == :fallback

View file

@ -48,6 +48,7 @@ class Contact < ApplicationRecord
before_validation :prepare_email_attribute
after_create_commit :dispatch_create_event, :ip_lookup
after_update_commit :dispatch_update_event
after_destroy_commit :dispatch_destroy_event
def get_source_id(inbox_id)
contact_inboxes.find_by!(inbox_id: inbox_id).source_id
@ -73,7 +74,8 @@ class Contact < ApplicationRecord
id: id,
name: name,
avatar: avatar_url,
type: 'contact'
type: 'contact',
account: account.webhook_data
}
end
@ -98,4 +100,8 @@ class Contact < ApplicationRecord
def dispatch_update_event
Rails.configuration.dispatcher.dispatch(CONTACT_UPDATED, Time.zone.now, contact: self)
end
def dispatch_destroy_event
Rails.configuration.dispatcher.dispatch(CONTACT_DELETED, Time.zone.now, contact: self)
end
end

View file

@ -5,7 +5,9 @@
# id :integer not null, primary key
# additional_attributes :jsonb
# agent_last_seen_at :datetime
# assignee_last_seen_at :datetime
# contact_last_seen_at :datetime
# custom_attributes :jsonb
# identifier :string
# last_activity_at :datetime not null
# snoozed_until :datetime

View file

@ -31,6 +31,7 @@ class Inbox < ApplicationRecord
include Avatarable
include OutOfOffisable
validates :name, presence: true
validates :account_id, presence: true
validates :timezone, inclusion: { in: TZInfo::Timezone.all_identifiers }
@ -82,6 +83,10 @@ class Inbox < ApplicationRecord
channel_type == 'Channel::Email'
end
def twilio?
channel_type == 'Channel::TwilioSms'
end
def inbox_type
channel.name
end
@ -93,9 +98,9 @@ class Inbox < ApplicationRecord
}
end
def webhook_url
def callback_webhook_url
case channel_type
when 'Channel::TwilioSMS'
when 'Channel::TwilioSms'
"#{ENV['FRONTEND_URL']}/twilio/callback"
when 'Channel::Line'
"#{ENV['FRONTEND_URL']}/webhooks/line/#{channel.line_channel_id}"

View file

@ -25,6 +25,8 @@ class Label < ApplicationRecord
format: { with: UNICODE_CHARACTER_NUMBER_HYPHEN_UNDERSCORE },
uniqueness: { scope: :account_id }
after_update_commit :update_associated_models
before_validation do
self.title = title.downcase if attribute_present?('title')
end
@ -40,4 +42,12 @@ class Label < ApplicationRecord
def events
account.events.where(conversation_id: conversations.pluck(:id))
end
private
def update_associated_models
return unless title_previously_changed?
Labels::UpdateJob.perform_later(title, title_previously_was, account_id)
end
end

View file

@ -40,4 +40,12 @@ class Team < ApplicationRecord
def remove_member(user_id)
team_members.find_by(user_id: user_id)&.destroy
end
def messages
account.messages.where(conversation_id: conversations.pluck(:id))
end
def events
account.events.where(conversation_id: conversations.pluck(:id))
end
end

View file

@ -43,10 +43,10 @@ class WorkingHour < ApplicationRecord
def open_at?(time)
return false if closed_all_day?
time.hour >= open_hour &&
time.min >= open_minutes &&
time.hour <= close_hour &&
time.min <= close_minutes
open_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: open_hour, min: open_minutes })
close_time = Time.zone.now.in_time_zone(inbox.timezone).change({ hour: close_hour, min: close_minutes })
time.between?(open_time, close_time)
end
def open_now?

View file

@ -30,4 +30,8 @@ class ContactPolicy < ApplicationPolicy
def create?
true
end
def destroy?
@account_user.administrator?
end
end

View file

@ -82,6 +82,10 @@ class MailPresenter < SimpleDelegator
@mail.from.map(&:downcase)
end
def sender_name
Mail::Address.new(@mail[:from].value).name
end
def original_sender
@mail['X-Original-Sender'].try(:value) || from.first
end

View file

@ -0,0 +1,35 @@
class Labels::UpdateService
pattr_initialize [:new_label_title!, :old_label_title!, :account_id!]
def perform
tagged_conversations.find_in_batches do |conversation_batch|
conversation_batch.each do |conversation|
conversation.label_list.remove(old_label_title)
conversation.label_list.add(new_label_title)
conversation.save!
end
end
tagged_contacts.find_in_batches do |contact_batch|
contact_batch.each do |contact|
contact.label_list.remove(old_label_title)
contact.label_list.add(new_label_title)
contact.save!
end
end
end
private
def tagged_conversations
account.conversations.tagged_with(old_label_title)
end
def tagged_contacts
account.contacts.tagged_with(old_label_title)
end
def account
@account ||= Account.find(account_id)
end
end

View file

@ -1,9 +1,17 @@
# ref : https://developers.line.biz/en/docs/messaging-api/receiving-messages/#webhook-event-types
# https://developers.line.biz/en/reference/messaging-api/#message-event
class Line::IncomingMessageService
include ::FileTypeHelper
pattr_initialize [:inbox!, :params!]
def perform
# probably test events
return if params[:events].blank?
line_contact_info
return if line_contact_info['userId'].blank?
set_contact
set_conversation
# TODO: iterate over the events and handle the attachments in future

View file

@ -14,14 +14,18 @@ class MessageTemplates::HookExecutionService
delegate :contact, to: :conversation
def trigger_templates
# TODO: let's see whether this is needed and remove this and related logic if not
# ::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::OutOfOffice.new(conversation: conversation).perform if should_send_out_of_office_message?
::MessageTemplates::Template::Greeting.new(conversation: conversation).perform if should_send_greeting?
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
::MessageTemplates::Template::CsatSurvey.new(conversation: conversation).perform if should_send_csat_survey?
end
def should_send_out_of_office_message?
# should not send if its a tweet message
return false if conversation.tweet?
# should not send for outbound messages
return false unless message.incoming?
inbox.out_of_office? && conversation.messages.today.template.empty? && inbox.out_of_office_message.present?
end

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