Merge branch 'develop' into ui/agent-dropdown

This commit is contained in:
Sivin Varghese 2021-05-13 21:48:28 +05:30 committed by GitHub
commit 9316995e62
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
176 changed files with 5398 additions and 801 deletions

View file

@ -30,3 +30,6 @@ exclude_patterns:
- "**/*.md"
- "**/*.yml"
- "app/javascript/dashboard/i18n/locale"
- "stories/**/*"
- "**/*.stories.js"
- "**/stories/"

View file

@ -101,6 +101,7 @@ Rails/BulkChangeTable:
- 'db/migrate/20170511134418_latlong.rb'
- 'db/migrate/20191027054756_create_contact_inboxes.rb'
- 'db/migrate/20191130164019_add_template_type_to_messages.rb'
- 'db/migrate/20210425093724_convert_integration_hook_settings_field.rb'
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'

View file

@ -8,8 +8,7 @@ const custom = require('../config/webpack/environment');
module.exports = {
stories: [
'../stories/**/*.stories.mdx',
'../app/javascript/dashboard/components/ui/stories/**/*.stories.@(js|jsx|ts|tsx)',
'../stories/**/*.stories.@(js|jsx|ts|tsx)',
'../app/javascript/**/*.stories.@(js|jsx|ts|tsx)',
],
addons: [
{

View file

@ -80,6 +80,8 @@ gem 'twitty'
gem 'koala'
# slack client
gem 'slack-ruby-client'
# for dialogflow integrations
gem 'google-cloud-dialogflow'
##--- gems for debugging and error reporting ---##
# static analysis
@ -105,6 +107,8 @@ gem 'maxminddb'
# to create db triggers
gem 'hairtrigger'
gem 'procore-sift'
group :development do
gem 'annotate'
gem 'bullet'
@ -112,7 +116,7 @@ group :development do
gem 'web-console'
# used in swagger build
gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: 'e32deb0'
gem 'json_refs', git: 'https://github.com/tzmfreedom/json_refs', ref: '131b11294fd6af9c428171f38516e6222a58c874'
# When we want to squash migrations
gem 'squasher'

View file

@ -7,10 +7,10 @@ GIT
GIT
remote: https://github.com/tzmfreedom/json_refs
revision: e32deb073ce9aef39bdd63556bffd7fe7c2a803d
ref: e32deb0
revision: 131b11294fd6af9c428171f38516e6222a58c874
ref: 131b11294fd6af9c428171f38516e6222a58c874
specs:
json_refs (0.1.2)
json_refs (0.1.6)
hana
GEM
@ -18,58 +18,58 @@ GEM
specs:
action-cable-testing (0.6.1)
actioncable (>= 5.0)
actioncable (6.0.3.6)
actionpack (= 6.0.3.6)
actioncable (6.0.3.7)
actionpack (= 6.0.3.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.0.3.6)
actionpack (= 6.0.3.6)
activejob (= 6.0.3.6)
activerecord (= 6.0.3.6)
activestorage (= 6.0.3.6)
activesupport (= 6.0.3.6)
actionmailbox (6.0.3.7)
actionpack (= 6.0.3.7)
activejob (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (= 6.0.3.7)
activesupport (= 6.0.3.7)
mail (>= 2.7.1)
actionmailer (6.0.3.6)
actionpack (= 6.0.3.6)
actionview (= 6.0.3.6)
activejob (= 6.0.3.6)
actionmailer (6.0.3.7)
actionpack (= 6.0.3.7)
actionview (= 6.0.3.7)
activejob (= 6.0.3.7)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.0.3.6)
actionview (= 6.0.3.6)
activesupport (= 6.0.3.6)
actionpack (6.0.3.7)
actionview (= 6.0.3.7)
activesupport (= 6.0.3.7)
rack (~> 2.0, >= 2.0.8)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.0.3.6)
actionpack (= 6.0.3.6)
activerecord (= 6.0.3.6)
activestorage (= 6.0.3.6)
activesupport (= 6.0.3.6)
actiontext (6.0.3.7)
actionpack (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (= 6.0.3.7)
activesupport (= 6.0.3.7)
nokogiri (>= 1.8.5)
actionview (6.0.3.6)
activesupport (= 6.0.3.6)
actionview (6.0.3.7)
activesupport (= 6.0.3.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.0.3.6)
activesupport (= 6.0.3.6)
activejob (6.0.3.7)
activesupport (= 6.0.3.7)
globalid (>= 0.3.6)
activemodel (6.0.3.6)
activesupport (= 6.0.3.6)
activerecord (6.0.3.6)
activemodel (= 6.0.3.6)
activesupport (= 6.0.3.6)
activemodel (6.0.3.7)
activesupport (= 6.0.3.7)
activerecord (6.0.3.7)
activemodel (= 6.0.3.7)
activesupport (= 6.0.3.7)
activerecord-import (1.0.7)
activerecord (>= 3.2)
activestorage (6.0.3.6)
actionpack (= 6.0.3.6)
activejob (= 6.0.3.6)
activerecord (= 6.0.3.6)
activestorage (6.0.3.7)
actionpack (= 6.0.3.7)
activejob (= 6.0.3.7)
activerecord (= 6.0.3.7)
marcel (~> 1.0.0)
activesupport (6.0.3.6)
activesupport (6.0.3.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@ -79,11 +79,10 @@ GEM
activerecord (>= 5.0, < 6.1)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
administrate (0.14.0)
actionpack (>= 4.2)
actionview (>= 4.2)
activerecord (>= 4.2)
autoprefixer-rails (>= 6.0)
administrate (0.16.0)
actionpack (>= 5.0)
actionview (>= 5.0)
activerecord (>= 5.0)
datetime_picker_rails (~> 0.0.7)
jquery-rails (>= 4.0)
kaminari (>= 1.0)
@ -95,8 +94,6 @@ GEM
rake (>= 10.4, < 14.0)
ast (2.4.1)
attr_extras (6.2.4)
autoprefixer-rails (9.8.6.3)
execjs
aws-eventstream (1.1.0)
aws-partitions (1.360.0)
aws-sdk-core (3.105.0)
@ -205,12 +202,18 @@ GEM
faraday (~> 1.0)
fcm (1.0.2)
faraday (~> 1.0.0)
ffi (1.14.2)
ffi (1.15.0)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.4.1)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.4)
gapic-common (0.3.4)
google-protobuf (~> 3.12, >= 3.12.2)
googleapis-common-protos (>= 1.3.9, < 2.0)
googleapis-common-protos-types (>= 1.0.4, < 2.0)
googleauth (~> 0.9)
grpc (~> 1.25)
geocoder (1.6.3)
gli (2.19.2)
globalid (0.4.2)
@ -223,12 +226,18 @@ GEM
representable (~> 3.0)
retriable (>= 2.0, < 4.0)
signet (~> 0.12)
google-cloud-core (1.5.0)
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-env (1.3.3)
google-cloud-dialogflow (1.2.0)
google-cloud-core (~> 1.5)
google-cloud-dialogflow-v2 (~> 0.1)
google-cloud-dialogflow-v2 (0.6.4)
gapic-common (~> 0.3)
google-cloud-errors (~> 1.0)
google-cloud-env (1.5.0)
faraday (>= 0.17.3, < 2.0)
google-cloud-errors (1.0.1)
google-cloud-errors (1.1.0)
google-cloud-storage (1.28.0)
addressable (~> 2.5)
digest-crc (~> 0.4)
@ -236,7 +245,14 @@ GEM
google-cloud-core (~> 1.2)
googleauth (~> 0.9)
mini_mime (~> 1.0)
googleauth (0.13.1)
google-protobuf (3.15.8)
googleapis-common-protos (1.3.11)
google-protobuf (~> 3.14)
googleapis-common-protos-types (>= 1.0.6, < 2.0)
grpc (~> 1.27)
googleapis-common-protos-types (1.0.6)
google-protobuf (~> 3.14)
googleauth (0.16.2)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.4, < 3.0)
memoist (~> 0.16)
@ -245,12 +261,15 @@ GEM
signet (~> 0.14)
groupdate (5.1.0)
activesupport (>= 5)
grpc (1.37.1)
google-protobuf (~> 3.15)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.0)
hairtrigger (0.2.23)
activerecord (>= 5.0, < 7)
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.6)
hana (1.3.7)
hashdiff (1.0.1)
hashie (4.1.0)
hkdf (0.3.0)
@ -261,7 +280,7 @@ GEM
mime-types (~> 3.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.8.9)
i18n (1.8.10)
concurrent-ruby (~> 1.0)
ice_nine (0.11.2)
inflecto (0.0.2)
@ -273,7 +292,7 @@ GEM
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.3.1)
jwt (2.2.2)
jwt (2.2.3)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@ -298,12 +317,12 @@ GEM
listen (3.3.3)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.9.0)
loofah (2.9.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
mini_mime (>= 0.1.1)
marcel (1.0.0)
marcel (1.0.1)
maxminddb (0.1.22)
memoist (0.16.2)
method_source (1.0.0)
@ -311,8 +330,8 @@ GEM
mime-types-data (~> 3.2015)
mime-types-data (3.2020.0512)
mini_magick (4.10.1)
mini_mime (1.0.3)
mini_portile2 (2.5.0)
mini_mime (1.1.0)
mini_portile2 (2.5.1)
minitest (5.14.4)
momentjs-rails (2.20.1)
railties (>= 3.1)
@ -324,7 +343,7 @@ GEM
connection_pool (~> 2.2)
netrc (0.11.0)
nio4r (2.5.7)
nokogiri (1.11.2)
nokogiri (1.11.3)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
oauth (0.5.6)
@ -334,12 +353,14 @@ GEM
parser (2.7.1.4)
ast (~> 2.4.1)
pg (1.2.3)
procore-sift (0.15.0)
rails (> 4.2.0)
pry (0.13.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.5)
public_suffix (4.0.6)
puma (4.3.6)
nio4r (~> 2.0)
pundit (2.1.0)
@ -355,29 +376,29 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (6.0.3.6)
actioncable (= 6.0.3.6)
actionmailbox (= 6.0.3.6)
actionmailer (= 6.0.3.6)
actionpack (= 6.0.3.6)
actiontext (= 6.0.3.6)
actionview (= 6.0.3.6)
activejob (= 6.0.3.6)
activemodel (= 6.0.3.6)
activerecord (= 6.0.3.6)
activestorage (= 6.0.3.6)
activesupport (= 6.0.3.6)
rails (6.0.3.7)
actioncable (= 6.0.3.7)
actionmailbox (= 6.0.3.7)
actionmailer (= 6.0.3.7)
actionpack (= 6.0.3.7)
actiontext (= 6.0.3.7)
actionview (= 6.0.3.7)
activejob (= 6.0.3.7)
activemodel (= 6.0.3.7)
activerecord (= 6.0.3.7)
activestorage (= 6.0.3.7)
activesupport (= 6.0.3.7)
bundler (>= 1.3.0)
railties (= 6.0.3.6)
railties (= 6.0.3.7)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.3.0)
loofah (~> 2.3)
railties (6.0.3.6)
actionpack (= 6.0.3.6)
activesupport (= 6.0.3.6)
railties (6.0.3.7)
actionpack (= 6.0.3.7)
activesupport (= 6.0.3.7)
method_source
rake (>= 0.8.7)
thor (>= 0.20.3, < 2.0)
@ -489,7 +510,7 @@ GEM
sidekiq-cron (1.2.0)
fugit (~> 1.1)
sidekiq (>= 4.2.1)
signet (0.14.0)
signet (0.15.0)
addressable (~> 2.3)
faraday (>= 0.17.3, < 2.0)
jwt (>= 1.5, < 3.0)
@ -612,6 +633,7 @@ DEPENDENCIES
flag_shih_tzu
foreman
geocoder
google-cloud-dialogflow
google-cloud-storage
groupdate
haikunator
@ -629,6 +651,7 @@ DEPENDENCIES
mini_magick
mock_redis!
pg
procore-sift
pry-rails
puma
pundit

View file

@ -0,0 +1,38 @@
class Campaigns::CampaignConversationBuilder
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes]
def perform
@contact_inbox = ContactInbox.find(@contact_inbox_id)
@campaign = @contact_inbox.inbox.campaigns.find_by!(display_id: campaign_display_id)
ActiveRecord::Base.transaction do
@contact_inbox.lock!
# We won't send campaigns if a conversation is already present
return if @contact_inbox.reload.conversations.present?
@conversation = ::Conversation.create!(conversation_params)
Messages::MessageBuilder.new(@campaign.sender, @conversation, message_params).perform
end
@conversation
end
private
def message_params
ActionController::Parameters.new({
content: @campaign.message
})
end
def conversation_params
{
account_id: @campaign.account_id,
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
campaign_id: @campaign.id,
additional_attributes: conversation_additional_attributes
}
end
end

View file

@ -1,4 +1,11 @@
class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
include Sift
sort_on :email, type: :string
sort_on :name, type: :string
sort_on :phone_number, type: :string
sort_on :last_activity_at, type: :datetime
RESULTS_PER_PAGE = 15
protect_from_forgery with: :null_session
@ -68,7 +75,6 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@resolved_contacts ||= Current.account.contacts
.where.not(email: [nil, ''])
.or(Current.account.contacts.where.not(phone_number: [nil, '']))
.order('LOWER(name)')
end
def set_current_page
@ -76,11 +82,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contact_last_seen_at(contacts)
contacts.left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count, MAX(conversations.contact_last_seen_at) as last_seen_at')
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }])
.page(@current_page).per(RESULTS_PER_PAGE)
filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }, { contact_inboxes: [:inbox] }])
.page(@current_page).per(RESULTS_PER_PAGE)
end
def build_contact_inbox

View file

@ -0,0 +1,13 @@
class Api::V1::Widget::CampaignsController < Api::V1::Widget::BaseController
skip_before_action :set_contact
def index
@campaigns = @web_widget.inbox.campaigns.where(enabled: true)
end
private
def permitted_params
params.permit(:website_token)
end
end

View file

@ -2,7 +2,8 @@ class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
include Events::Types
def create
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox, event_info: event_info)
Rails.configuration.dispatcher.dispatch(permitted_params[:name], Time.zone.now, contact_inbox: @contact_inbox,
event_info: permitted_params[:event_info].to_h.merge(event_info))
head :no_content
end
@ -17,6 +18,6 @@ class Api::V1::Widget::EventsController < Api::V1::Widget::BaseController
end
def permitted_params
params.permit(:name, :website_token)
params.permit(:name, :website_token, event_info: {})
end
end

View file

@ -12,7 +12,8 @@ class AsyncDispatcher < BaseDispatcher
[
EventListener.instance,
WebhookListener.instance,
InstallationWebhookListener.instance, HookListener.instance
InstallationWebhookListener.instance, HookListener.instance,
CampaignListener.instance
]
end
end

View file

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

View file

@ -6,8 +6,8 @@ class ContactAPI extends ApiClient {
super('contacts', { accountScoped: true });
}
get(page) {
return axios.get(`${this.url}?page=${page}`);
get(page, sortAttr = 'name') {
return axios.get(`${this.url}?page=${page}&sort=${sortAttr}`);
}
getConversations(contactId) {
@ -18,8 +18,10 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
search(search = '', page = 1) {
return axios.get(`${this.url}/search?q=${search}&page=${page}`);
search(search = '', page = 1, sortAttr = 'name') {
return axios.get(
`${this.url}/search?q=${search}&page=${page}&sort=${sortAttr}`
);
}
}

View file

@ -15,10 +15,6 @@
}
}
.label {
font-weight: $font-weight-bold;
}
.tooltip {
border-radius: $space-smaller;
font-size: $font-size-mini;

View file

@ -358,7 +358,7 @@ $form-label-font-weight: $font-weight-medium;
$form-label-line-height: 1.8;
$select-background: $white;
$select-triangle-color: $dark-gray;
$select-radius: $global-radius;
$select-radius: var(--border-radius-normal);
$input-color: $header-color;
$input-placeholder-color: $light-gray;
$input-font-family: inherit;
@ -374,19 +374,19 @@ $input-shadow-focus: 0;
$input-cursor-disabled: not-allowed;
$input-transition: border-color 0.25s ease-in-out;
$input-number-spinners: true;
$input-radius: $global-radius;
$form-button-radius: $global-radius;
$input-radius: var(--border-radius-normal);
$form-button-radius: var(--border-radius-normal);
// 20. Label
// ---------
$label-background: lighten($primary-color, 40%);
$label-color: $primary-color;
$label-background: $primary-color;
$label-color: $white;
$label-color-alt: $black;
$label-palette: $foundation-palette;
$label-font-size: $font-size-micro;
$label-font-size: $font-size-mini;
$label-padding: $space-smaller $space-small;
$label-radius: $space-micro;
$label-radius: var(--border-radius-small);
// 21. Media Object
// ----------------

View file

@ -51,25 +51,6 @@
}
}
input,
textarea,
select {
border-radius: var(--space-smaller) !important;
}
.input-group {
.input-group-label:first-child {
border-bottom-left-radius: var(--space-smaller);
border-top-left-radius: var(--space-smaller);
}
.input-group-field {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
}
.justify-space-between {
justify-content: space-between;
}

View file

@ -236,3 +236,11 @@ $spinner-before-border-color: rgba(255, 255, 255, 0.7);
text-overflow: ellipsis;
white-space: nowrap;
}
@mixin three-column-grid($column-one-width: 25.6rem,
$column-three-width: 25.6rem) {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: minmax($column-one-width, 6fr) 10fr minmax($column-three-width, 6fr);
}

View file

@ -24,3 +24,4 @@
@import 'foundation-custom';
@import 'widgets/buttons';
@import 'widgets/forms';

View file

@ -39,20 +39,25 @@ $resolve-button-width: 13.2rem;
.user {
@include flex;
@include flex-align($x: center, $y: middle);
margin-right: var(--space-normal);
min-width: 0;
.user--name {
@include margin(0);
display: inline-block;
font-size: $font-size-medium;
line-height: 1.3;
text-transform: capitalize;
width: 100%;
}
.user--profile__meta {
align-items: flex-start;
display: flex;
flex-direction: column;
justify-content: center;
justify-content: flex-start;
margin-left: $space-slab;
min-width: 0;
}
.user--profile__button {

View file

@ -132,7 +132,6 @@
.bubble {
@include bubble-with-types;
max-width: 50rem;
text-align: left;
word-wrap: break-word;
@ -236,7 +235,9 @@
.wrap {
@include margin($zero $space-normal);
max-width: 69%;
--bubble-max-width: 49.6rem;
max-width: Min(var(--bubble-max-width), 85%);
.sender--name {
font-size: $font-size-mini;

View file

@ -1,7 +1,8 @@
.error {
#{$all-text-inputs},
select,
.multiselect > .multiselect__tags {
.multiselect>.multiselect__tags {
@include thin-border(var(--r-400));
}
@ -33,3 +34,10 @@ input {
.help-text {
font-weight: $font-weight-normal;
}
.input-group.small {
input {
font-size: var(--font-size-small);
height: var(--space-large);
}
}

View file

@ -6,8 +6,10 @@ import Button from './ui/WootButton';
import Code from './Code';
import ColorPicker from './widgets/ColorPicker';
import DeleteModal from './widgets/modal/DeleteModal.vue';
import DropdownItem from 'shared/components/ui/dropdown/DropdownItem';
import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import Input from './widgets/forms/Input.vue';
import Label from './widgets/Label.vue';
import Label from './ui/Label';
import LoadingState from './widgets/LoadingState';
import Modal from './Modal';
import ModalHeader from './ModalHeader';
@ -26,6 +28,8 @@ const WootUIKit = {
Code,
ColorPicker,
DeleteModal,
DropdownItem,
DropdownMenu,
Input,
LoadingState,
Label,

View file

@ -20,22 +20,28 @@
:key="status.value"
class="status-items"
>
<button
class="button clear status-change--dropdown-button"
:disabled="status.disabled"
<woot-button
variant="clear"
class-names="status-change--dropdown-button"
:is-disabled="status.disabled"
@click="changeAvailabilityStatus(status.value)"
>
<availability-status-badge :status="status.value" />
{{ status.label }}
</button>
</woot-button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
</transition>
<button class="status-change--change-button" @click="openStatusMenu">
<woot-button
variant="clear"
color-scheme="secondary"
class-names="status-change--change-button link"
@click="openStatusMenu"
>
{{ $t('SIDEBAR_ITEMS.CHANGE_AVAILABILITY_STATUS') }}
</button>
</woot-button>
</div>
</div>
</template>
@ -156,15 +162,5 @@ export default {
display: flex;
align-items: baseline;
}
& &--change-button {
color: var(--b-600);
font-size: var(--font-size-small);
cursor: pointer;
outline: none;
&:hover {
color: var(--w-600);
}
}
}
</style>

View file

@ -144,6 +144,7 @@ export default {
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes`),
toStateName: 'settings_inbox_list',
newLinkRouteName: 'settings_inbox_new',
children: this.inboxes.map(inbox => ({
id: inbox.id,
label: inbox.name,
@ -158,10 +159,13 @@ export default {
icon: 'ion-pound',
label: 'LABELS',
hasSubMenu: true,
newLink: true,
key: 'label',
cssClass: 'menu-title align-justify',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
toStateName: 'labels_list',
showModalForNewItem: true,
modalName: 'AddLabel',
children: this.accountLabels.map(label => ({
id: label.id,
label: label.title,
@ -178,10 +182,12 @@ export default {
icon: 'ion-ios-people',
label: 'TEAMS',
hasSubMenu: true,
newLink: true,
key: 'team',
cssClass: 'menu-title align-justify teams-sidebar-menu',
toState: frontendURL(`accounts/${this.accountId}/settings/teams`),
toStateName: 'teams_list',
newLinkRouteName: 'settings_teams_new',
children: this.teams.map(team => ({
id: team.id,
label: team.name,

View file

@ -19,7 +19,7 @@
<span
v-if="showItem(menuItem)"
class="child-icon ion-android-add-circle"
@click.prevent="newLinkClick"
@click.prevent="newLinkClick(menuItem)"
/>
</a>
<ul v-if="menuItem.hasSubMenu" class="nested vertical menu">
@ -52,6 +52,11 @@
</a>
</router-link>
</ul>
<add-label-modal
v-if="showAddLabel"
:show.sync="showAddLabel"
:on-close="hideAddLabelPopup"
/>
</router-link>
</template>
@ -61,8 +66,17 @@ import { mapGetters } from 'vuex';
import router from '../../routes';
import adminMixin from '../../mixins/isAdmin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
export default {
components: {
AddLabelModal,
},
mixins: [adminMixin],
data() {
return {
showAddLabel: false,
};
},
props: {
menuItem: {
type: Object,
@ -108,12 +122,24 @@ export default {
if (!child.truncateLabel) return false;
return child.label;
},
newLinkClick() {
router.push({ name: 'settings_inbox_new', params: { page: 'new' } });
newLinkClick(item) {
if (item.newLinkRouteName) {
router.push({ name: item.newLinkRouteName, params: { page: 'new' } });
} else if (item.showModalForNewItem) {
if (item.modalName === 'AddLabel') {
this.showAddLabelPopup();
}
}
},
showItem(item) {
return this.isAdmin && item.newLink !== undefined;
},
showAddLabelPopup() {
this.showAddLabel = true;
},
hideAddLabelPopup() {
this.showAddLabel = false;
},
},
};
</script>

View file

@ -3,11 +3,13 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import WootButton from 'dashboard/components/ui/WootButton';
import i18n from 'dashboard/i18n';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.component('woot-button', WootButton);
const i18nConfig = new VueI18n({
locale: 'en',

View file

@ -4,7 +4,7 @@ import SidemenuIcon from '../SidemenuIcon';
describe('SidemenuIcon', () => {
test('matches snapshot', () => {
const wrapper = mount(SidemenuIcon);
expect(wrapper.isVueInstance()).toBeTruthy();
expect(wrapper.vm).toBeTruthy();
expect(wrapper.element).toMatchSnapshot();
});
});

View file

@ -0,0 +1,148 @@
<template>
<div :class="labelClass" :style="labelStyle" :title="description">
<i v-if="icon" class="label--icon" :class="icon" @click="onClick" />
<span v-if="!href">{{ title }}</span>
<a v-else :href="href" :style="anchorStyle">{{ title }}</a>
<i v-if="showClose" class="close--icon ion-close" @click="onClick" />
</div>
</template>
<script>
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
export default {
props: {
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
href: {
type: String,
default: '',
},
bgColor: {
type: String,
default: '',
},
small: {
type: Boolean,
default: false,
},
showClose: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
colorScheme: {
type: String,
default: '',
},
},
computed: {
textColor() {
return getContrastingTextColor(this.bgColor);
},
labelClass() {
return `label ${this.colorScheme} ${this.small ? 'small' : ''}`;
},
labelStyle() {
if (this.bgColor) {
return { background: this.bgColor, color: this.textColor };
}
return {};
},
anchorStyle() {
if (this.bgColor) {
return { color: this.textColor };
}
return {};
},
},
methods: {
onClick() {
this.$emit('click', this.title);
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label {
font-weight: var(--font-weight-medium);
margin-right: var(--space-smaller);
margin-bottom: var(--space-smaller);
&.small {
font-size: var(--font-size-micro);
}
.label--icon {
cursor: pointer;
}
.label--icon,
.close--icon {
font-size: var(--font-size-micro);
}
&.small .label--icon,
&.small .close--icon {
font-size: var(--font-size-nano);
}
a {
font-size: var(--font-size-mini);
&:hover {
text-decoration: underline;
}
}
/* Color Schemes */
&.primary {
background: var(--w-100);
color: var(--w-900);
border: 1px solid var(--w-200);
a {
color: var(--w-900);
}
}
&.secondary {
background: var(--s-100);
color: var(--s-900);
border: 1px solid var(--s-200);
a {
color: var(--s-900);
}
}
&.success {
background: var(--g-100);
color: var(--g-900);
border: 1px solid var(--g-200);
a {
color: var(--g-900);
}
}
&.alert {
background: var(--r-100);
color: var(--r-900);
border: 1px solid var(--r-200);
a {
color: var(--r-900);
}
}
&.warning {
background: var(--y-100);
color: var(--y-900);
border: 1px solid var(--y-300);
a {
color: var(--y-900);
}
}
}
</style>

View file

@ -0,0 +1,64 @@
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Label',
argTypes: {
title: {
defaultValue: 'sales',
control: {
type: 'text',
},
},
colorScheme: {
control: {
type: 'select',
options: ['primary', 'secondary', 'success', 'alert', 'warning'],
},
},
description: {
defaultValue: 'label',
control: {
type: 'text',
},
},
href: {
control: {
type: 'text',
},
},
bgColor: {
defaultValue: '#a83262',
control: {
type: 'text',
},
},
small: {
defaultValue: false,
control: {
type: 'boolean',
},
},
showClose: {
defaultValue: false,
control: {
type: 'boolean',
},
},
icon: {
defaultValue: 'ion-close',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
template: '<woot-label v-bind="$props" @click="onClick"></woot-label>',
});
export const DefaultLabel = Template.bind({});
DefaultLabel.args = {
onClick: action('clicked'),
};

View file

@ -1,87 +0,0 @@
<template>
<div
:class="labelClass"
:style="{ background: bgColor, color: textColor }"
:title="description"
>
<span v-if="!href">{{ title }}</span>
<a v-else :href="href" :style="{ color: textColor }">{{ title }}</a>
<i v-if="showIcon" class="label--icon" :class="icon" @click="onClick" />
</div>
</template>
<script>
import { getContrastingTextColor } from 'shared/helpers/ColorHelper';
export default {
props: {
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
href: {
type: String,
default: '',
},
bgColor: {
type: String,
default: '#1f93ff',
},
small: {
type: Boolean,
default: false,
},
showIcon: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: 'ion-close',
},
},
computed: {
textColor() {
return getContrastingTextColor(this.bgColor);
},
labelClass() {
return `label ${this.small ? 'small' : ''}`;
},
},
methods: {
onClick() {
this.$emit('click', this.title);
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.label {
display: inline-block;
font-size: $font-size-small;
line-height: 1;
margin: $space-micro;
&.small {
font-size: $font-size-mini;
}
a {
&:hover {
text-decoration: underline;
}
}
}
.label--icon {
cursor: pointer;
font-size: $font-size-micro;
line-height: 1.5;
margin-left: $space-smaller;
}
</style>

View file

@ -12,39 +12,41 @@
v-if="totalCount"
class="primary button-group pagination-button-group"
>
<button
class="button small goto-first"
:class="firstPageButtonClass"
<woot-button
size="small"
class-names="goto-first"
:is-disabled="hasFirstPage"
@click="onFirstPage"
>
<i class="ion-chevron-left" />
<i class="ion-chevron-left" />
</button>
<button
class="button small"
:class="prevPageButtonClass"
</woot-button>
<woot-button
size="small"
:is-disabled="hasPrevPage"
@click="onPrevPage"
>
<i class="ion-chevron-left" />
</button>
<button class="button" @click.prevent>
</woot-button>
<woot-button @click.prevent>
{{ currentPage }}
</button>
<button
class="button small"
:class="nextPageButtonClass"
</woot-button>
<woot-button
size="small"
:is-disabled="hasNextPage"
@click="onNextPage"
>
<i class="ion-chevron-right" />
</button>
<button
class="button small goto-last"
:class="lastPageButtonClass"
</woot-button>
<woot-button
size="small"
class-names="goto-last"
:is-disabled="hasLastPage"
@click="onLastPage"
>
<i class="ion-chevron-right" />
<i class="ion-chevron-right" />
</button>
</woot-button>
</div>
</div>
</footer>
@ -91,35 +93,19 @@ export default {
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
},
lastPageButtonClass() {
const className = this.hasLastPage ? 'disabled' : '';
return className;
},
hasFirstPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
},
firstPageButtonClass() {
const className = this.hasFirstPage ? 'disabled' : '';
return className;
},
hasNextPage() {
const isDisabled =
this.currentPage === Math.ceil(this.totalCount / this.pageSize);
return isDisabled;
},
nextPageButtonClass() {
const className = this.hasNextPage ? 'disabled' : '';
return className;
},
hasPrevPage() {
const isDisabled = this.currentPage === 1;
return isDisabled;
},
prevPageButtonClass() {
const className = this.hasPrevPage ? 'disabled' : '';
return className;
},
},
methods: {
onNextPage() {

View file

@ -80,6 +80,7 @@ export default {
.conversation-details-wrap {
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
border-left: 1px solid var(--color-border);
background: var(--color-background-light);

View file

@ -186,10 +186,12 @@ export default {
if (this.isPrivate) {
return MESSAGE_MAX_LENGTH.GENERAL;
}
if (this.isAFacebookInbox) {
return MESSAGE_MAX_LENGTH.FACEBOOK;
}
if (this.isATwilioWhatsappChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_WHATSAPP;
}
if (this.isATwilioSMSChannel) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}

View file

@ -1,9 +1,79 @@
{
"CAMPAIGN": {
"HEADER": "Campaigns",
"HEADER_BTN_TXT": "Create Campaign",
"SIDEBAR_TXT": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations. Click on <b>Add Campaign</b> to create a new campaign. You can also edit or delete an existing campaign by clicking on the Edit or Delete button.",
"HEADER_BTN_TXT": "Create a campaign",
"ADD": {
"TITLE": "Create a campaign",
"DESC": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations.",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
"ERROR": "Message is required"
},
"SENT_BY": {
"LABEL": "Sent by",
"PLACEHOLDER": "Please select the the content of campaign",
"ERROR": "Sender is required"
},
"END_POINT": {
"LABEL": "URL",
"PLACEHOLDER": "Please enter the URL",
"ERROR": "Please enter a valid URL"
},
"TIME_ON_PAGE": {
"LABEL": "Time on page(Seconds)",
"PLACEHOLDER": "Please enter the time",
"ERROR": "Time on page is required"
},
"ENABLED": "Enable campaign",
"SUBMIT": "Add Campaign"
},
"API": {
"SUCCESS_MESSAGE": "Campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
},
"EDIT": {
"TITLE": "Edit campaign",
"UPDATE_BUTTON_TEXT": "Update",
"API": {
"SUCCESS_MESSAGE": "Campaign updated successfully",
"ERROR_MESSAGE": "There was an error, please try again"
}
},
"LIST": {
"404": "There are no campaigns attached to this inbox"
"LOADING_MESSAGE": "Loading campaigns...",
"404": "There are no campaigns created for this inbox.",
"TABLE_HEADER": {
"TITLE": "Title",
"MESSAGE": "Message",
"STATUS": "Status",
"SENDER": "Sender",
"URL": "URL",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
},
"BUTTONS": {
"ADD": "Add",
"EDIT": "Edit",
"DELETE": "Delete"
},
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"SENDER": {
"BOT": "Bot"
}
}
}
}

View file

@ -28,11 +28,17 @@
"INACTIVE_LABELS": "Labels available in the account",
"REMOVE": "Click on X icon to remove the label",
"ADD": "Click on + icon to add the label",
"ADD_BUTTON": "Add Labels",
"UPDATE_BUTTON": "Update labels",
"UPDATE_ERROR": "Couldn't update labels, try again."
},
"NO_LABELS_TO_ADD": "There are no more labels defined in the account.",
"NO_AVAILABLE_LABELS": "There are no labels added to this conversation."
"NO_AVAILABLE_LABELS": "There are no labels added to this conversation.",
"LABEL_SELECT": {
"TITLE": "Add Labels",
"PLACEHOLDER": "Search labels",
"NO_RESULT": "No labels found"
}
},
"MUTE_CONTACT": "Mute Conversation",
"UNMUTE_CONTACT": "Unmute Conversation",
@ -151,5 +157,18 @@
},
"VIEW_DETAILS": "View details"
}
},
"NOTES": {
"HEADER": {
"TITLE": "Notes"
},
"ADD": {
"BUTTON": "Add",
"PLACEHOLDER": "Add a note",
"TITLE": "Shift + Enter to create a note"
},
"FOOTER": {
"BUTTON": "View all notes"
}
}
}

View file

@ -0,0 +1,125 @@
<template>
<div class="contact-attribute">
<div class="title-wrap">
<h4 class="text-block-title title">
<div class="title--icon">
<emoji-or-icon :icon="icon" :emoji="emoji" />
</div>
{{ label }}
</h4>
</div>
<div v-show="isEditing">
<div class="input-group small">
<input
ref="inputfield"
v-model="editedValue"
type="text"
class="input-group-field"
autofocus="true"
@keyup.enter="onUpdate"
/>
<div class="input-group-button">
<woot-button size="small" icon="ion-checkmark" @click="onUpdate" />
</div>
</div>
</div>
<div
v-show="!isEditing"
class="value--view"
:class="{ 'is-editable': showEdit }"
>
<p class="value">
{{ value || '---' }}
</p>
<woot-button
v-if="showEdit"
variant="clear link"
size="tiny"
color-scheme="secondary"
icon="ion-compose"
class-names="edit-button"
@click="onEdit"
/>
</div>
</div>
</template>
<script>
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
export default {
components: {
EmojiOrIcon,
},
props: {
label: { type: String, required: true },
icon: { type: String, default: '' },
emoji: { type: String, default: '' },
value: { type: [String, Number], default: '' },
showEdit: { type: Boolean, default: false },
},
data() {
return {
isEditing: false,
editedValue: this.value,
};
},
methods: {
focusInput() {
this.$refs.inputfield.focus();
},
onEdit() {
this.isEditing = true;
this.$nextTick(() => {
this.focusInput();
});
},
onUpdate() {
this.isEditing = false;
this.$emit('update', this.editedValue);
},
},
};
</script>
<style lang="scss" scoped>
.contact-attribute {
margin-bottom: var(--space-small);
}
.title-wrap {
display: flex;
align-items: center;
margin-bottom: var(--space-mini);
}
.title {
display: flex;
align-items: center;
margin: 0;
}
.title--icon {
width: var(--space-two);
}
.edit-button {
display: none;
}
.value--view {
display: flex;
&.is-editable:hover {
.value {
background: var(--color-background);
}
.edit-button {
display: block;
}
}
}
.value {
display: inline-block;
min-width: var(--space-mega);
border-radius: var(--border-radius-small);
word-break: break-all;
margin: 0 var(--space-smaller) 0 var(--space-normal);
padding: var(--space-micro) var(--space-smaller);
}
</style>

View file

@ -0,0 +1,74 @@
<template>
<div class="contact-fields">
<h3 class="block-title title">Contact fields</h3>
<attribute
:label="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
icon="ion-email"
emoji=""
:value="contact.email"
:show-edit="true"
@update="onEmailUpdate"
/>
<attribute
:label="$t('CONTACT_PANEL.PHONE_NUMBER')"
icon="ion-ios-telephone"
emoji=""
:value="contact.phone_number"
:show-edit="true"
@update="onPhoneUpdate"
/>
<attribute
v-if="additionalAttributes.location"
:label="$t('CONTACT_PANEL.LOCATION')"
icon="ion-map"
emoji="🌍"
:value="additionalAttributes.location"
:show-edit="true"
@update="onLocationUpdate"
/>
</div>
</template>
<script>
import Attribute from './ContactAttribute';
export default {
components: { Attribute },
props: {
contact: {
type: Object,
default: () => ({}),
},
},
computed: {
additionalAttributes() {
return this.contact.additional_attributes || {};
},
company() {
const { company = {} } = this.contact;
return company;
},
},
methods: {
onEmailUpdate(value) {
this.$emit('update', { email: value });
},
onPhoneUpdate(value) {
this.$emit('update', { phone: value });
},
onLocationUpdate(value) {
this.$emit('update', { location: value });
},
},
};
</script>
<style scoped lang="scss">
.contact-fields {
margin-top: var(--space-medium);
}
.title {
margin-bottom: var(--space-normal);
}
</style>

View file

@ -0,0 +1,121 @@
<template>
<div class="contact--intro">
<thumbnail
:src="contact.thumbnail"
size="64px"
:username="contact.name"
:status="contact.availability_status"
/>
<div class="contact--details">
<h2 class="block-title contact--name">
{{ contact.name }}
</h2>
<h3 class="sub-block-title contact--work">
{{ contact.title }}
<i v-if="company.name" class="icon ion-minus-round" />
<span class="company-name">{{ company.name }}</span>
</h3>
<p v-if="additionalAttributes.description" class="contact--bio">
{{ additionalAttributes.description }}
</p>
<social-icons :social-profiles="socialProfiles" />
</div>
<div class="contact-actions">
<woot-button
class="new-message"
size="small expanded"
icon="ion-paper-airplane"
@click="onNewMessageClick"
>
{{ $t('CONTACT_PANEL.NEW_MESSAGE') }}
</woot-button>
<woot-button
variant="hollow"
size="small expanded"
icon="ion-compose"
@click="onEditClick"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
import SocialIcons from 'dashboard/routes/dashboard/conversation/contact/SocialIcons';
export default {
components: {
Thumbnail,
SocialIcons,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
},
computed: {
additionalAttributes() {
return this.contact.additional_attributes || {};
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
} = this.additionalAttributes;
return { twitter: twitterScreenName, ...(socialProfiles || {}) };
},
company() {
const { company = {} } = this.contact;
return company;
},
},
methods: {
onEditClick() {
this.$emit('edit');
},
onNewMessageClick() {
this.$emit('message');
},
},
};
</script>
<style scoped lang="scss">
.contact--details {
margin-top: var(--space-small);
}
.contact--work {
color: var(--color-body);
.icon {
font-size: var(--font-size-nano);
vertical-align: middle;
}
}
.contact--name {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
}
.contact--bio {
margin: var(--space-smaller) 0 0;
}
.button.new-message {
margin-right: var(--space-small);
}
.contact-actions {
display: flex;
align-items: center;
width: 100%;
margin-top: var(--space-small);
}
</style>

View file

@ -0,0 +1,62 @@
<template>
<div class="panel">
<contact-intro
:contact="contact"
@message="toggleConversationModal"
@edit="toggleEditModal"
/>
<contact-fields :contact="contact" :edit="null" />
<edit-contact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<new-conversation
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
</div>
</template>
<script>
import EditContact from 'dashboard/routes/dashboard/conversation/contact/EditContact';
import NewConversation from 'dashboard/routes/dashboard/conversation/contact/NewConversation';
import ContactIntro from './ContactIntro';
import ContactFields from './ContactFields';
export default {
components: {
EditContact,
NewConversation,
ContactIntro,
ContactFields,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
},
data() {
return {
showEditModal: false,
showConversationModal: false,
};
},
methods: {
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
},
},
};
</script>
<style scoped lang="scss">
.panel {
padding: var(--space-normal) var(--space-normal);
}
</style>

View file

@ -0,0 +1,66 @@
<template>
<div class="wrap">
<div class="left">
<contact-panel v-if="!uiFlags.isFetchingItem" :contact="contact" />
</div>
<div class="center"></div>
<div class="right"></div>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import ContactPanel from './ContactPanel';
export default {
components: {
ContactPanel,
},
props: {
contactId: {
type: [String, Number],
default: 0,
},
},
data() {
return {};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
return hasEmptyResults;
},
contact() {
return this.$store.getters['contacts/getContact'](this.contactId);
},
},
mounted() {
this.getContactDetails();
},
methods: {
getContactDetails() {
if (this.contactId) {
this.$store.dispatch('contacts/show', { id: this.contactId });
}
},
},
};
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/mixins';
.wrap {
@include three-column-grid(27.2rem);
background: var(--color-background);
border-top: 1px solid var(--color-border);
}
.center {
border-right: 1px solid var(--color-border);
border-left: 1px solid var(--color-border);
}
</style>

View file

@ -0,0 +1,43 @@
import ContactAttribute from '../components/ContactAttribute';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/ContactAttribute',
component: ContactAttribute,
argTypes: {
label: {
defaultValue: 'Email',
control: {
type: 'text',
},
},
value: {
defaultValue: 'dwight@schrute.farms',
control: {
type: 'text',
},
},
icon: {
defaultValue: 'ion-email',
control: {
type: 'text',
},
},
showEdit: {
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactAttribute },
template: '<contact-attribute v-bind="$props" @update="onEdit" />',
});
export const DefaultAttribute = Template.bind({});
DefaultAttribute.args = {
onEdit: action('edit'),
};

View file

@ -0,0 +1,42 @@
import ContactFields from '../components/ContactFields';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/ContactFields',
component: ContactFields,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactFields },
template:
'<contact-fields v-bind="$props" :contact="contact" @update="onUpdate" />',
});
export const DefaultContactFields = Template.bind({});
DefaultContactFields.args = {
contact: {
id: 979442,
name: 'Eden Hazard',
title: 'Playmaker',
thumbnail: 'https://randomuser.me/api/portraits/men/19.jpg',
company: {
id: 10,
name: 'Chelsea',
},
email: 'hazard@chelsea.com',
availability_status: 'offline',
phone_number: '',
custom_attributes: {},
additional_attributes: {
description:
'Known for his dribbling, he is considered to be one of the best players in the world.',
social_profiles: {
twitter: 'hazardeden10',
facebook: 'hazardeden10',
linkedin: 'hazardeden10',
},
},
},
onUpdate: action('update'),
};

View file

@ -0,0 +1,43 @@
import ContactIntro from '../components/ContactIntro';
import { action } from '@storybook/addon-actions';
export default {
title: 'Components/Contact/ContactIntro',
component: ContactIntro,
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactIntro },
template:
'<contact-intro v-bind="$props" :user="user" @edit="onEdit" @message="onNewMessage" />',
});
export const DefaultContactIntro = Template.bind({});
DefaultContactIntro.args = {
contact: {
id: 979442,
name: 'Eden Hazard',
title: 'Playmaker',
thumbnail: 'https://randomuser.me/api/portraits/men/19.jpg',
company: {
id: 10,
name: 'Chelsea',
},
email: 'hazard@chelsea.com',
availability_status: 'offline',
phone_number: '',
custom_attributes: {},
additional_attributes: {
description:
'Known for his dribbling, he is considered to be one of the best players in the world.',
social_profiles: {
twitter: 'hazardeden10',
facebook: 'hazardeden10',
linkedin: 'hazardeden10',
},
},
},
onEdit: action('edit'),
onNewMessage: action('new message 💬'),
};

View file

@ -0,0 +1,19 @@
import { action } from '@storybook/addon-actions';
import AddNote from './AddNote.vue';
export default {
title: 'Components/Notes/Add',
component: AddNote,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddNote },
template: '<add-note v-bind="$props" @add="onAdd"></add-note>',
});
export const Add = Template.bind({});
Add.args = {
onAdd: action('Added'),
};

View file

@ -0,0 +1,96 @@
<template>
<div class="wrap">
<div class="input-wrap">
<textarea
v-model="inputText"
:placeholder="$t('NOTES.ADD.PLACEHOLDER')"
class="input--note"
@keydown.enter.shift.exact="onAdd"
>
</textarea>
</div>
<div class="footer">
<woot-button
size="tiny"
color-scheme="warning"
:title="$t('NOTES.ADD.TITLE')"
:is-disabled="buttonDisabled"
class="button-wrap"
@click="onAdd"
>
{{ $t('NOTES.ADD.BUTTON') }}
</woot-button>
</div>
</div>
</template>
<script>
import WootButton from 'dashboard/components/ui/WootButton.vue';
export default {
components: {
WootButton,
},
data() {
return {
inputText: '',
};
},
computed: {
buttonDisabled() {
return this.inputText === '';
},
},
methods: {
onAdd() {
if (this.inputText !== '') {
this.$emit('add', this.inputText);
}
this.inputText = '';
},
},
};
</script>
<style lang="scss" scoped>
.wrap {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-smaller);
width: 100%;
.input-wrap {
display: flex;
flex: 0 0 auto;
width: 100%;
.input--note {
font-size: var(--font-size-mini);
border-color: transparent;
padding: var(--space-small) var(--space-small) 0 var(--space-small);
resize: none;
box-sizing: border-box;
min-height: var(--space-larger);
margin-bottom: var(--space-small);
}
}
.footer {
display: flex;
justify-content: flex-end;
flex: 1 1 auto;
overflow: auto;
width: 100%;
.button-wrap {
float: right;
margin-bottom: var(--space-small);
margin-right: var(--space-small);
}
}
}
</style>

View file

@ -0,0 +1,52 @@
import { action } from '@storybook/addon-actions';
import ContactNote from './ContactNote.vue';
export default {
title: 'Components/Notes/Note',
component: ContactNote,
argTypes: {
id: {
control: {
type: 'number',
},
},
note: {
defaultValue:
'A copy and paste musical notes symbols & music symbols collection for easy access.',
control: {
type: 'text',
},
},
userName: {
defaultValue: 'John Doe',
control: {
type: 'text',
},
},
timeStamp: {
defaultValue: '1618046084',
control: {
type: 'number',
},
},
thumbnail: {
defaultValue: 'https://randomuser.me/api/portraits/men/62.jpg',
control: {
type: 'text',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { ContactNote },
template:
'<contact-note v-bind="$props" @edit="onEdit" @delete="onDelete"></contact-note>',
});
export const Note = Template.bind({});
Note.args = {
onEdit: action('Edit'),
onDelete: action('Delete'),
};

View file

@ -6,7 +6,7 @@
<div class="footer">
<div class="meta">
<div :title="userName">
<Thumbnail :src="thumbnail" :username="userName" size="16" />
<Thumbnail :src="thumbnail" :username="userName" size="16px" />
</div>
<div class="date-wrap">
<span>{{ readableTime }}</span>
@ -50,6 +50,10 @@ export default {
mixins: [timeMixin],
props: {
id: {
type: Number,
default: 0,
},
note: {
type: String,
default: '',
@ -76,10 +80,10 @@ export default {
methods: {
onEdit() {
this.$emit('edit');
this.$emit('edit', this.id);
},
onDelete() {
this.$emit('delete');
this.$emit('delete', this.id);
},
},
};
@ -90,6 +94,7 @@ export default {
padding: var(--space-small);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-smaller);
.text {
padding-bottom: var(--space-small);

View file

@ -7,6 +7,7 @@
:columns="columns"
:table-data="tableData"
:border-around="false"
:sort-option="sortOption"
/>
<empty-state
@ -57,37 +58,82 @@ export default {
type: [String, Number],
default: '',
},
sortParam: {
type: String,
default: 'name',
},
sortOrder: {
type: String,
default: 'asc',
},
},
data() {
return {
columns: [
sortConfig: {},
sortOption: {
sortAlways: true,
sortChange: params => this.$emit('on-sort-change', params),
},
};
},
computed: {
tableData() {
if (this.isLoading) {
return [];
}
return this.contacts.map(item => {
// Note: The attributes used here is in snake case
// as it simplier the sort attribute calculation
const additional = item.additional_attributes || {};
const { last_activity_at: lastActivityAt } = item;
return {
...item,
phone_number: item.phone_number || '---',
company: additional.company_name || '---',
location: additional.location || '---',
profiles: additional.social_profiles || {},
city: additional.city || '---',
country: additional.country || '---',
conversations_count: item.conversations_count || '---',
last_activity_at: lastActivityAt
? this.dynamicTime(lastActivityAt)
: '---',
};
});
},
columns() {
return [
{
field: 'name',
key: 'name',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.NAME'),
fixed: 'left',
align: 'left',
sortBy: this.sortConfig.name || '',
width: 300,
renderBodyCell: ({ row }) => (
<button
class="row--user-block cursor-pointer"
<woot-button
variant="clear"
size="expanded"
onClick={() => this.onClickContact(row.id)}
>
<Thumbnail
src={row.thumbnail}
size="36px"
username={row.name}
status={row.availability_status}
/>
<div>
<h6 class="sub-block-title user-name text-truncate">
{row.name}
</h6>
<button class="button clear small">
{this.$t('CONTACTS_PAGE.LIST.VIEW_DETAILS')}
</button>
<div class="row--user-block">
<Thumbnail
src={row.thumbnail}
size="36px"
username={row.name}
status={row.availability_status}
/>
<div class="user-block">
<h6 class="sub-block-title user-name text-truncate">
{row.name}
</h6>
<span class="button clear small">
{this.$t('CONTACTS_PAGE.LIST.VIEW_DETAILS')}
</span>
</div>
</div>
</button>
</woot-button>
),
},
{
@ -95,6 +141,7 @@ export default {
key: 'email',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.EMAIL_ADDRESS'),
align: 'left',
sortBy: this.sortConfig.email || '',
width: 240,
renderBodyCell: ({ row }) => {
if (row.email)
@ -113,8 +160,9 @@ export default {
},
},
{
field: 'phone',
key: 'phone',
field: 'phone_number',
key: 'phone_number',
sortBy: this.sortConfig.phone_number || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.PHONE_NUMBER'),
align: 'left',
},
@ -167,8 +215,9 @@ export default {
},
},
{
field: 'lastSeen',
key: 'lastSeen',
field: 'last_activity_at',
key: 'last_activity_at',
sortBy: this.sortConfig.last_activity_at || '',
title: this.$t('CONTACTS_PAGE.LIST.TABLE_HEADER.LAST_ACTIVITY'),
align: 'left',
},
@ -179,29 +228,23 @@ export default {
width: 150,
align: 'left',
},
],
};
];
},
},
computed: {
tableData() {
if (this.isLoading) {
return [];
}
return this.contacts.map(item => {
const additional = item.additional_attributes || {};
const { last_seen_at: lastSeenAt } = item;
return {
...item,
phone: item.phone_number || '---',
company: additional.company_name || '---',
location: additional.location || '---',
profiles: additional.social_profiles || {},
city: additional.city || '---',
country: additional.country || '---',
conversationsCount: item.conversations_count || '---',
lastSeen: lastSeenAt ? this.dynamicTime(lastSeenAt) : '---',
};
});
watch: {
sortOrder() {
this.setSortConfig();
},
sortParam() {
this.setSortConfig();
},
},
mounted() {
this.setSortConfig();
},
methods: {
setSortConfig() {
this.sortConfig = { [this.sortParam]: this.sortOrder };
},
},
};
@ -225,6 +268,10 @@ export default {
display: flex;
text-align: left;
.user-block {
min-width: 0;
}
.user-thumbnail-box {
margin-right: var(--space-small);
}
@ -251,6 +298,9 @@ export default {
.ve-table-header-th {
font-size: var(--font-size-mini) !important;
}
.ve-table-sort {
top: -4px;
}
}
.contacts--loader {

View file

@ -14,6 +14,8 @@
:is-loading="uiFlags.isFetching"
:on-click-contact="openContactInfoPanel"
:active-contact-id="selectedContactId"
:sort-config="sortConfig"
@on-sort-change="onSortChange"
/>
<table-footer
:on-page-change="onPageChange"
@ -39,6 +41,8 @@ import ContactInfoPanel from './ContactInfoPanel';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact';
import TableFooter from 'dashboard/components/widgets/TableFooter';
const DEFAULT_PAGE = 1;
export default {
components: {
ContactsHeader,
@ -52,6 +56,7 @@ export default {
searchQuery: '',
showCreateModal: false,
selectedContactId: '',
sortConfig: { name: 'asc' },
};
},
computed: {
@ -81,43 +86,63 @@ export default {
},
pageParameter() {
const selectedPageNumber = Number(this.$route.query?.page);
return !Number.isNaN(selectedPageNumber) && selectedPageNumber >= 1
return !Number.isNaN(selectedPageNumber) &&
selectedPageNumber >= DEFAULT_PAGE
? selectedPageNumber
: 1;
: DEFAULT_PAGE;
},
},
mounted() {
this.$store.dispatch('contacts/get', { page: this.pageParameter });
this.fetchContacts(this.pageParameter);
},
methods: {
updatePageParam(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
},
getSortAttribute() {
let sortAttr = Object.keys(this.sortConfig).reduce((acc, sortKey) => {
const sortOrder = this.sortConfig[sortKey];
if (sortOrder) {
const sortOrderSign = sortOrder === 'asc' ? '' : '-';
return `${sortOrderSign}${sortKey}`;
}
return acc;
}, '');
if (!sortAttr) {
this.sortConfig = { name: 'asc' };
sortAttr = 'name';
}
return sortAttr;
},
fetchContacts(page) {
this.updatePageParam(page);
const requestParams = { page, sortAttr: this.getSortAttribute() };
if (!this.searchQuery) {
this.$store.dispatch('contacts/get', requestParams);
} else {
this.$store.dispatch('contacts/search', {
search: this.searchQuery,
...requestParams,
});
}
},
onInputSearch(event) {
const newQuery = event.target.value;
const refetchAllContacts = !!this.searchQuery && newQuery === '';
if (refetchAllContacts) {
this.$store.dispatch('contacts/get', { page: 1 });
}
this.searchQuery = newQuery;
if (refetchAllContacts) {
this.fetchContacts(DEFAULT_PAGE);
}
},
onSearchSubmit() {
this.selectedContactId = '';
if (this.searchQuery) {
this.$store.dispatch('contacts/search', {
search: this.searchQuery,
page: 1,
});
this.fetchContacts(DEFAULT_PAGE);
}
},
onPageChange(page) {
this.selectedContactId = '';
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
if (this.searchQuery) {
this.$store.dispatch('contacts/search', {
search: this.searchQuery,
page,
});
} else {
this.$store.dispatch('contacts/get', { page });
}
this.fetchContacts(page);
},
openContactInfoPanel(contactId) {
this.selectedContactId = contactId;
@ -130,6 +155,10 @@ export default {
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
},
onSortChange(params) {
this.sortConfig = params;
this.fetchContacts(this.meta.currentPage);
},
},
};
</script>
@ -138,6 +167,7 @@ export default {
.contacts-page {
width: 100%;
}
.left-wrap {
display: flex;
flex-direction: column;

View file

@ -17,18 +17,22 @@
@keyup.enter="onSearchSubmit"
@input="onInputSearch"
/>
<woot-submit-button
:button-text="$t('CONTACTS_PAGE.SEARCH_BUTTON')"
:loading="false"
:button-class="searchButtonClass"
<woot-button
:is-loading="false"
:class-names="searchButtonClass"
@click="onSearchSubmit"
/>
>
{{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
</woot-button>
</div>
<button class="button success icon" @click="onToggleCreate">
<i class="icon ion-android-add-circle" />
<woot-button
color-scheme="success"
icon="ion-android-add-circle"
@click="onToggleCreate"
>
{{ $t('CREATE_CONTACT.BUTTON_LABEL') }}
</button>
</woot-button>
</div>
</div>
</header>

View file

@ -0,0 +1,45 @@
import { action } from '@storybook/addon-actions';
import noteList from './NoteList';
export default {
title: 'Components/Notes/List',
component: noteList,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { noteList },
template:
'<note-list v-bind="$props" @addNote="onAddNote" @editNote="onEditNote" @deleteNote="onDeleteNote" @show="onClick"></note-list>',
});
export const List = Template.bind({});
List.args = {
onClick: action('show'),
onAddNote: action('added'),
onEditNote: action('edit'),
onDeleteNote: action('deleted'),
notes: [
{
id: '12345',
content:
'It is a long established fact that a reader will be distracted.',
user: {
name: 'John Doe',
thumbnail: 'https://randomuser.me/api/portraits/men/69.jpg',
},
created_at: 1618046084,
},
{
id: '12346',
content:
'It is simply dummy text of the printing and typesetting industry.',
user: {
name: 'Pearl Cruz',
thumbnail: 'https://randomuser.me/api/portraits/women/29.jpg',
},
created_at: 1616046076,
},
],
};

View file

@ -0,0 +1,67 @@
<template>
<div>
<div class="notelist-wrap">
<h3 class="block-title">
{{ $t('NOTES.HEADER.TITLE') }}
</h3>
<add-note @add="onAddNote" />
<contact-note
v-for="note in notes"
:id="note.id"
:key="note.id"
:note="note.content"
:user-name="note.user.name"
:time-stamp="note.created_at"
:thumbnail="note.user.thumbnail"
@edit="onEditNote"
@delete="onDeleteNote"
/>
<div class="button-wrap">
<woot-button variant="clear link" class="button" @click="onclick">
{{ $t('NOTES.FOOTER.BUTTON') }}
<i class="ion-arrow-right-c" />
</woot-button>
</div>
</div>
</div>
</template>
<script>
import ContactNote from './ContactNote';
import AddNote from './AddNote';
export default {
components: {
ContactNote,
AddNote,
},
props: {
notes: {
type: Array,
default: () => [],
},
},
methods: {
onclick() {
this.$emit('show');
},
onAddNote(value) {
this.$emit('addNote', value);
},
onEditNote(value) {
this.$emit('editNote', value);
},
onDeleteNote(value) {
this.$emit('deleteNote', value);
},
},
};
</script>
<style lang="scss" scoped>
.button-wrap {
margin-top: var(--space-one);
}
</style>

View file

@ -1,12 +1,29 @@
<template>
<div class="contact-manage-view row"></div>
<div class="contact-manage-view">
<contacts-header
:search-query="searchQuery"
:on-search-submit="onSearchSubmit"
:on-input-search="onInputSearch"
:on-toggle-create="onToggleCreate"
/>
<manage-layout :contact-id="contactId" />
<create-contact :show="showCreateModal" @cancel="onToggleCreate" />
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import ContactsHeader from '../components/Header';
import ManageLayout from 'dashboard/modules/contact/components/ManageLayout';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact';
export default {
components: {},
components: {
ContactsHeader,
CreateContact,
ManageLayout,
},
props: {
contactId: {
type: [String, Number],
@ -14,7 +31,10 @@ export default {
},
},
data() {
return {};
return {
searchQuery: '',
showCreateModal: false,
};
},
computed: {
...mapGetters({
@ -26,7 +46,28 @@ export default {
},
},
mounted() {},
methods: {},
methods: {
onInputSearch(event) {
const newQuery = event.target.value;
const refetchAllContacts = !!this.searchQuery && newQuery === '';
if (refetchAllContacts) {
this.$store.dispatch('contacts/get', { page: 1 });
}
this.searchQuery = newQuery;
},
onSearchSubmit() {
this.selectedContactId = '';
if (this.searchQuery) {
this.$store.dispatch('contacts/search', {
search: this.searchQuery,
page: 1,
});
}
},
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
},
},
};
</script>

View file

@ -21,7 +21,7 @@
:title="label.title"
:description="label.description"
:bg-color="label.color"
:show-icon="true"
:show-close="true"
@click="onRemove"
/>
</div>
@ -44,7 +44,6 @@
:title="label.title"
:description="label.description"
:bg-color="label.color"
:show-icon="true"
icon="ion-plus"
@click="onAdd"
/>

View file

@ -258,7 +258,7 @@
<weekly-availability :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'campaign'">
<campaign />
<campaign :selected-agents="selectedAgents" />
</div>
</div>
</template>
@ -335,10 +335,6 @@ export default {
if (this.isAWebWidgetInbox) {
return [
...visibleToAllChannelTabs,
{
key: 'campaign',
name: this.$t('INBOX_MGMT.TABS.CAMPAIGN'),
},
{
key: 'preChatForm',
name: this.$t('INBOX_MGMT.TABS.PRE_CHAT_FORM'),

View file

@ -56,7 +56,8 @@ import { required } from 'vuelidate/lib/validators';
import router from '../../../../index';
import PageHeader from '../../SettingsSubPageHeader';
const shouldBeWebhookUrl = (value = '') => value.startsWith('http');
const shouldBeWebhookUrl = (value = '') =>
value ? value.startsWith('http') : true;
export default {
components: {
@ -76,7 +77,7 @@ export default {
},
validations: {
channelName: { required },
webhookUrl: { required, shouldBeWebhookUrl },
webhookUrl: { shouldBeWebhookUrl },
},
methods: {
async createChannel() {

View file

@ -0,0 +1,214 @@
<template>
<modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header
:header-title="$t('CAMPAIGN.ADD.TITLE')"
:header-content="$t('CAMPAIGN.ADD.DESC')"
/>
<form class="row" @submit.prevent="addCampaign">
<div class="medium-12 columns">
<label :class="{ error: $v.title.$error }">
{{ $t('CAMPAIGN.ADD.FORM.TITLE.LABEL') }}
<input
v-model.trim="title"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.TITLE.PLACEHOLDER')"
@input="$v.title.$touch"
/>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.message.$error }">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
<textarea
v-model.trim="message"
rows="5"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@input="$v.message.$touch"
/>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.selectedSender.$error }">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.LABEL') }}
<select v-model="selectedSender">
<option
v-for="sender in sendersAndBotList"
:key="sender.name"
:value="sender.id"
>
{{ sender.name }}
</option>
</select>
<span v-if="$v.selectedSender.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.endPoint.$error }">
{{ $t('CAMPAIGN.ADD.FORM.END_POINT.LABEL') }}
<input
v-model.trim="endPoint"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.END_POINT.PLACEHOLDER')"
@input="$v.endPoint.$touch"
/>
<span v-if="$v.endPoint.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.END_POINT.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.timeOnPage.$error }">
{{ $t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.LABEL') }}
<input
v-model.trim="timeOnPage"
type="number"
:placeholder="$t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.PLACEHOLDER')"
@input="$v.timeOnPage.$touch"
/>
<span v-if="$v.timeOnPage.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label>
<input
v-model="enabled"
type="checkbox"
value="enabled"
name="enabled"
/>
{{ $t('CAMPAIGN.ADD.FORM.ENABLED') }}
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-submit-button
:disabled="buttonDisabled"
:loading="uiFlags.isCreating"
:button-text="$t('CAMPAIGN.ADD.CREATE_BUTTON_TEXT')"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('CAMPAIGN.ADD.CANCEL_BUTTON_TEXT') }}
</button>
</div>
</div>
</form>
</div>
</modal>
</template>
<script>
import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators';
import Modal from 'dashboard/components/Modal';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Modal,
},
mixins: [alertMixin],
props: {
senderList: {
type: Array,
default: () => [],
},
onClose: {
type: Function,
default: () => {},
},
},
data() {
return {
title: '',
message: '',
selectedSender: 0,
endPoint: '',
timeOnPage: 10,
show: true,
enabled: true,
};
},
validations: {
title: {
required,
},
message: {
required,
},
selectedSender: {
required,
},
endPoint: {
required,
minLength: minLength(7),
url,
},
timeOnPage: {
required,
},
},
computed: {
...mapGetters({
uiFlags: 'campaigns/getUIFlags',
}),
buttonDisabled() {
return (
this.$v.message.$invalid ||
this.$v.title.$invalid ||
this.$v.selectedSender.$invalid ||
this.$v.endPoint.$invalid ||
this.$v.timeOnPage.$invalid ||
this.uiFlags.isCreating
);
},
sendersAndBotList() {
return [
{
id: 0,
name: 'Bot',
},
...this.senderList,
];
},
},
methods: {
async addCampaign() {
try {
await this.$store.dispatch('campaigns/create', {
title: this.title,
message: this.message,
inbox_id: this.$route.params.inboxId,
sender_id: this.selectedSender || null,
enabled: this.enabled,
trigger_rules: {
url: this.endPoint,
time_on_page: this.timeOnPage,
},
});
this.showAlert(this.$t('CAMPAIGN.ADD.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
this.showAlert(this.$t('CAMPAIGN.ADD.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.content-box .page-top-bar::v-deep {
padding: var(--space-large) var(--space-large) var(--space-zero);
}
</style>

View file

@ -1,37 +1,92 @@
<template>
<div class="column content-box">
<div class="row">
<a class="button icon success nice button--fixed-right-top">
<i class="icon ion-android-add-circle"></i>
<div class="row button-wrapper">
<woot-button icon="ion-android-add-circle" @click="openAddPopup">
{{ $t('CAMPAIGN.HEADER_BTN_TXT') }}
</a>
</woot-button>
</div>
<campaigns-table
:campaigns="records"
:show-empty-result="showEmptyResult"
:is-loading="uiFlags.isFetching"
:on-edit-click="openEditPopup"
/>
<div class="row">
<div class="small-8 columns">
<p class="no-items-error-message">
{{ $t('CAMPAIGN.LIST.404') }}
<a>
{{ $t('CAMPAIGN.HEADER_BTN_TXT') }}
</a>
</p>
</div>
<div class="small-4 columns">
<span>
<p>
<b> {{ $t('CAMPAIGN.HEADER') }}</b>
</p>
<p>
Proactive messages allows customer send outbound messages to their
contacts which would trigger more conversations. Campaigns are tied
to inbox. Click on
<b>Add Campaign</b>
to create a new campaign. You can also edit or delete an existing
campaigns Response by clicking on the Edit or Delete button.
</p>
</span>
</div>
</div>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<add-campaign :on-close="hideAddPopup" :sender-list="selectedAgents" />
</woot-modal>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<edit-campaign
:on-close="hideEditPopup"
:selected-campaign="selectedCampaign"
:sender-list="selectedAgents"
/>
</woot-modal>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import AddCampaign from './AddCampaign';
import CampaignsTable from './CampaignsTable';
import EditCampaign from './EditCampaign';
export default {
components: {
AddCampaign,
CampaignsTable,
EditCampaign,
},
props: {
selectedAgents: {
type: Array,
default: () => [],
},
},
data() {
return {
campaigns: [],
showAddPopup: false,
showEditPopup: false,
selectedCampaign: {},
};
},
computed: {
...mapGetters({
records: 'campaigns/getCampaigns',
uiFlags: 'campaigns/getUIFlags',
}),
showEmptyResult() {
const hasEmptyResults =
!this.uiFlags.isFetching && this.records.length === 0;
return hasEmptyResults;
},
},
mounted() {
this.$store.dispatch('campaigns/get');
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
openEditPopup(response) {
const { row: campaign } = response;
this.selectedCampaign = campaign;
this.showEditPopup = true;
},
hideEditPopup() {
this.showEditPopup = false;
},
},
};
</script>
<style scoped lang="scss">
.button-wrapper {
display: flex;
justify-content: flex-end;
padding-bottom: var(--space-one);
}
</style>

View file

@ -0,0 +1,47 @@
<template>
<div class="row--user-block">
<Thumbnail
:src="sender.thumbnail"
size="20px"
:username="sender.name"
:status="sender.availability_status"
/>
<div>
<h6 class="text-block-title text-truncate">
{{ sender.name }}
</h6>
</div>
</div>
</template>
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
},
props: {
sender: {
type: Object,
default: () => {},
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.row--user-block {
align-items: center;
display: flex;
text-align: left;
.user-name {
margin: 0;
text-transform: capitalize;
}
.user-thumbnail-box {
margin-right: var(--space-small);
}
}
</style>

View file

@ -0,0 +1,213 @@
<template>
<section class="campaigns-table-wrap">
<ve-table
:columns="columns"
scroll-width="155rem"
:table-data="tableData"
:border-around="true"
/>
<empty-state v-if="showEmptyResult" :title="$t('CAMPAIGN.LIST.404')" />
<div v-if="isLoading" class="campaign--loader">
<spinner />
<span>{{ $t('CAMPAIGN.LIST.LOADING_MESSAGE') }}</span>
</div>
</section>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import { VeTable } from 'vue-easytable';
import Spinner from 'shared/components/Spinner.vue';
import Label from 'dashboard/components/ui/Label';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import WootButton from 'dashboard/components/ui/WootButton.vue';
import CampaignSender from './CampaignSender';
export default {
components: {
EmptyState,
Spinner,
VeTable,
},
mixins: [clickaway],
props: {
campaigns: {
type: Array,
default: () => [],
},
showEmptyResult: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
onEditClick: {
type: Function,
default: () => {},
},
},
data() {
return {
columns: [
{
field: 'title',
key: 'title',
title: this.$t('CAMPAIGN.LIST.TABLE_HEADER.TITLE'),
fixed: 'left',
align: 'left',
renderBodyCell: ({ row }) => (
<div class="row--title-block">
<h6 class="sub-block-title title text-truncate">{row.title}</h6>
</div>
),
},
{
field: 'message',
key: 'message',
title: this.$t('CAMPAIGN.LIST.TABLE_HEADER.MESSAGE'),
align: 'left',
width: 350,
renderBodyCell: ({ row }) => {
return (
<div class="text-truncate">
<span title={row.message}>{row.message}</span>
</div>
);
},
},
{
field: 'enabled',
key: 'enabled',
title: this.$t('CAMPAIGN.LIST.TABLE_HEADER.STATUS'),
align: 'left',
renderBodyCell: ({ row }) => {
const labelText = row.enabled
? this.$t('CAMPAIGN.LIST.STATUS.ENABLED')
: this.$t('CAMPAIGN.LIST.STATUS.DISABLED');
const colorScheme = row.enabled ? 'success' : 'secondary';
return <Label title={labelText} colorScheme={colorScheme} />;
},
},
{
field: 'sender',
key: 'sender',
title: this.$t('CAMPAIGN.LIST.TABLE_HEADER.SENDER'),
align: 'left',
renderBodyCell: ({ row }) => {
if (row.sender) return <CampaignSender sender={row.sender} />;
return this.$t('CAMPAIGN.LIST.SENDER.BOT');
},
},
{
field: 'url',
key: 'url',
title: this.$t('CAMPAIGN.LIST.TABLE_HEADER.URL'),
align: 'left',
renderBodyCell: ({ row }) => (
<div class="text-truncate">
<a
target="_blank"
rel="noopener noreferrer nofollow"
href={row.url}
title={row.url}
>
{row.url}
</a>
</div>
),
},
{
field: 'timeOnPage',
key: 'timeOnPage',
title: this.$t('CAMPAIGN.LIST.TABLE_HEADER.TIME_ON_PAGE'),
align: 'left',
},
{
field: 'buttons',
key: 'buttons',
title: '',
align: 'left',
renderBodyCell: (row) => (
<div class="button-wrapper">
<WootButton
variant="clear"
icon="ion-edit"
color-scheme="secondary"
classNames="hollow grey-btn"
onClick={() => this.onEditClick(row)}
>
{this.$t('CAMPAIGN.LIST.BUTTONS.EDIT')}
</WootButton>
</div>
),
},
],
};
},
computed: {
tableData() {
if (this.isLoading) {
return [];
}
return this.campaigns.map((item) => {
return {
...item,
url: item.trigger_rules.url,
timeOnPage: item.trigger_rules.time_on_page,
};
});
},
},
};
</script>
<style lang="scss" scoped>
.campaigns-table-wrap::v-deep {
.ve-table {
padding-bottom: var(--space-large);
thead.ve-table-header .ve-table-header-tr .ve-table-header-th {
font-size: var(--font-size-mini);
padding: var(--space-small) var(--space-two);
}
tbody.ve-table-body .ve-table-body-tr .ve-table-body-td {
padding: var(--space-slab) var(--space-two);
}
}
.row--title-block {
align-items: center;
display: flex;
text-align: left;
.title {
font-size: var(--font-size-small);
margin: 0;
text-transform: capitalize;
}
}
.label {
padding: var(--space-smaller) var(--space-small);
}
}
.campaign--loader {
align-items: center;
display: flex;
font-size: var(--font-size-default);
justify-content: center;
padding: var(--space-big);
}
.button-wrapper {
justify-content: space-evenly;
display: flex;
flex-direction: row;
min-width: 20rem;
}
</style>

View file

@ -0,0 +1,246 @@
<template>
<modal :show.sync="show" :on-close="onClose">
<div class="column content-box">
<woot-modal-header :header-title="pageTitle" />
<form class="row" @submit.prevent="editCampaign">
<div class="medium-12 columns">
<label :class="{ error: $v.title.$error }">
{{ $t('CAMPAIGN.ADD.FORM.TITLE.LABEL') }}
<input
v-model.trim="title"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.TITLE.PLACEHOLDER')"
@input="$v.title.$touch"
/>
<span v-if="$v.title.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.TITLE.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.message.$error }">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
<textarea
v-model.trim="message"
rows="5"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@input="$v.message.$touch"
/>
<span v-if="$v.message.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.selectedSender.$error }">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.LABEL') }}
<select v-model="selectedSender">
<option
v-for="sender in sendersAndBotList"
:key="sender.name"
:value="sender.id"
>
{{ sender.name }}
</option>
</select>
<span v-if="$v.selectedSender.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.endPoint.$error }">
{{ $t('CAMPAIGN.ADD.FORM.END_POINT.LABEL') }}
<input
v-model.trim="endPoint"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.END_POINT.PLACEHOLDER')"
@input="$v.endPoint.$touch"
/>
<span v-if="$v.endPoint.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.END_POINT.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.timeOnPage.$error }">
{{ $t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.LABEL') }}
<input
v-model.trim="timeOnPage"
type="number"
:placeholder="$t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.PLACEHOLDER')"
@input="$v.timeOnPage.$touch"
/>
<span v-if="$v.timeOnPage.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.ERROR') }}
</span>
</label>
</div>
<div class="medium-12 columns">
<label>
<input
v-model="enabled"
type="checkbox"
value="enabled"
name="enabled"
/>
{{ $t('CAMPAIGN.ADD.FORM.ENABLED') }}
</label>
</div>
<div class="modal-footer">
<woot-button :disabled="buttonDisabled" :loading="uiFlags.isCreating">
{{ $t('CAMPAIGN.EDIT.UPDATE_BUTTON_TEXT') }}
</woot-button>
<woot-button
class="button clear"
:disabled="buttonDisabled"
:loading="uiFlags.isCreating"
@click.prevent="onClose"
>
{{ $t('CAMPAIGN.ADD.CANCEL_BUTTON_TEXT') }}
</woot-button>
</div>
</form>
</div>
</modal>
</template>
<script>
import { mapGetters } from 'vuex';
import { required, url, minLength } from 'vuelidate/lib/validators';
import Modal from 'dashboard/components/Modal';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Modal,
},
mixins: [alertMixin],
props: {
onClose: {
type: Function,
default: () => {},
},
selectedCampaign: {
type: Object,
default: () => {},
},
senderList: {
type: Array,
default: () => [],
},
},
data() {
return {
title: '',
message: '',
selectedSender: '',
endPoint: '',
timeOnPage: 10,
show: true,
enabled: true,
};
},
validations: {
title: {
required,
},
message: {
required,
},
selectedSender: {
required,
},
endPoint: {
required,
minLength: minLength(7),
url,
},
timeOnPage: {
required,
},
},
computed: {
...mapGetters({
uiFlags: 'campaigns/getUIFlags',
}),
buttonDisabled() {
return (
this.$v.message.$invalid ||
this.$v.title.$invalid ||
this.$v.selectedSender.$invalid ||
this.$v.endPoint.$invalid ||
this.$v.timeOnPage.$invalid ||
this.uiFlags.isCreating
);
},
pageTitle() {
return `${this.$t('CAMPAIGN.EDIT.TITLE')} - ${
this.selectedCampaign.title
}`;
},
sendersAndBotList() {
return [
{
id: 0,
name: 'Bot',
},
...this.senderList,
];
},
},
mounted() {
this.setFormValues();
},
methods: {
setFormValues() {
const {
title,
message,
enabled,
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
sender,
} = this.selectedCampaign;
this.title = title;
this.message = message;
this.endPoint = endPoint;
this.timeOnPage = timeOnPage;
this.selectedSender = (sender && sender.id) || 0;
this.enabled = enabled;
},
async editCampaign() {
try {
await this.$store.dispatch('campaigns/update', {
id: this.selectedCampaign.id,
title: this.title,
message: this.message,
inbox_id: this.$route.params.inboxId,
sender_id: this.selectedSender || null,
enabled: this.enabled,
trigger_rules: {
url: this.endPoint,
time_on_page: this.timeOnPage,
},
});
this.showAlert(this.$t('CAMPAIGN.EDIT.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
this.showAlert(this.$t('CAMPAIGN.EDIT.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped>
.content-box .page-top-bar::v-deep {
padding: var(--space-large) var(--space-large) var(--space-zero);
}
</style>

View file

@ -37,16 +37,14 @@
</label>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-submit-button
:disabled="$v.title.$invalid || uiFlags.isCreating"
:button-text="$t('LABEL_MGMT.FORM.CREATE')"
:loading="uiFlags.isCreating"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('LABEL_MGMT.FORM.CANCEL') }}
</button>
</div>
<woot-submit-button
:disabled="$v.title.$invalid || uiFlags.isCreating"
:button-text="$t('LABEL_MGMT.FORM.CREATE')"
:loading="uiFlags.isCreating"
/>
<button class="button clear" @click.prevent="onClose">
{{ $t('LABEL_MGMT.FORM.CANCEL') }}
</button>
</div>
</form>
</div>

View file

@ -26,6 +26,7 @@ import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import teams from './modules/teams';
import teamMembers from './modules/teamMembers';
import campaigns from './modules/campaigns';
Vue.use(Vuex);
export default new Vuex.Store({
@ -55,5 +56,6 @@ export default new Vuex.Store({
webhooks,
teams,
teamMembers,
campaigns,
},
});

View file

@ -0,0 +1,77 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CampaignsAPI from '../../api/campaigns';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
},
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getCampaigns(_state) {
return _state.records;
},
};
export const actions = {
get: async function getCampaigns({ commit }) {
commit(types.SET_CAMPAIGN_UI_FLAG, { isFetching: true });
try {
const response = await CampaignsAPI.get();
commit(types.SET_CAMPAIGNS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isFetching: false });
}
},
create: async function createCampaign({ commit }, campaignObj) {
commit(types.SET_CAMPAIGN_UI_FLAG, { isCreating: true });
try {
const response = await CampaignsAPI.create(campaignObj);
commit(types.ADD_CAMPAIGN, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { id, ...updateObj }) => {
commit(types.SET_CAMPAIGN_UI_FLAG, { isUpdating: true });
try {
const response = await CampaignsAPI.update(id, updateObj);
commit(types.EDIT_CAMPAIGN, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isUpdating: false });
}
},
};
export const mutations = {
[types.SET_CAMPAIGN_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.ADD_CAMPAIGN]: MutationHelpers.create,
[types.SET_CAMPAIGNS]: MutationHelpers.set,
[types.EDIT_CAMPAIGN]: MutationHelpers.update,
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View file

@ -6,12 +6,12 @@ import types from '../../mutation-types';
import ContactAPI from '../../../api/contacts';
export const actions = {
search: async ({ commit }, { search, page }) => {
search: async ({ commit }, { search, page, sortAttr }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.search(search, page);
} = await ContactAPI.search(search, page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
@ -21,12 +21,12 @@ export const actions = {
}
},
get: async ({ commit }, { page = 1 } = {}) => {
get: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.get(page);
} = await ContactAPI.get(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);

View file

@ -1,6 +1,6 @@
export const getters = {
getContacts($state) {
return Object.values($state.records);
return $state.sortOrder.map(contactId => $state.records[contactId]);
},
getUIFlags($state) {
return $state.uiFlags;

View file

@ -14,6 +14,7 @@ const state = {
isFetchingInboxes: false,
isUpdating: false,
},
sortOrder: [],
};
export default {

View file

@ -11,6 +11,7 @@ export const mutations = {
[types.CLEAR_CONTACTS]: $state => {
Vue.set($state, 'records', {});
Vue.set($state, 'sortOrder', []);
},
[types.SET_CONTACT_META]: ($state, data) => {
@ -20,12 +21,14 @@ export const mutations = {
},
[types.SET_CONTACTS]: ($state, data) => {
data.forEach(contact => {
const sortOrder = data.map(contact => {
Vue.set($state.records, contact.id, {
...($state.records[contact.id] || {}),
...contact,
});
return contact.id;
});
$state.sortOrder = sortOrder;
},
[types.SET_CONTACT_ITEM]: ($state, data) => {
@ -33,6 +36,10 @@ export const mutations = {
...($state.records[data.id] || {}),
...data,
});
if (!$state.sortOrder.includes(data.id)) {
$state.sortOrder.push(data.id);
}
},
[types.EDIT_CONTACT]: ($state, data) => {

View file

@ -0,0 +1,71 @@
import axios from 'axios';
import { actions } from '../../campaigns';
import * as types from '../../../mutation-types';
import campaignList from './fixtures';
const commit = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: campaignList });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_CAMPAIGN_UI_FLAG, { isFetching: true }],
[types.default.SET_CAMPAIGNS, campaignList],
[types.default.SET_CAMPAIGN_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_CAMPAIGN_UI_FLAG, { isFetching: true }],
[types.default.SET_CAMPAIGN_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: campaignList[0] });
await actions.create({ commit }, campaignList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_CAMPAIGN_UI_FLAG, { isCreating: true }],
[types.default.ADD_CAMPAIGN, campaignList[0]],
[types.default.SET_CAMPAIGN_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_CAMPAIGN_UI_FLAG, { isCreating: true }],
[types.default.SET_CAMPAIGN_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: campaignList[0] });
await actions.update({ commit }, campaignList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_CAMPAIGN_UI_FLAG, { isUpdating: true }],
[types.default.EDIT_CAMPAIGN, campaignList[0]],
[types.default.SET_CAMPAIGN_UI_FLAG, { isUpdating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.update({ commit }, campaignList[0])).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CAMPAIGN_UI_FLAG, { isUpdating: true }],
[types.default.SET_CAMPAIGN_UI_FLAG, { isUpdating: false }],
]);
});
});
});

View file

@ -0,0 +1,348 @@
export default [
{
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
greeting_enabled: true,
greeting_message: '',
working_hours_enabled: true,
out_of_office_message:
'We are unavailable at the moment. Leave a message we will respond once we are back.',
working_hours: [
{
day_of_week: 0,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
},
{
day_of_week: 1,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 2,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 3,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 5,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 6,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
},
],
timezone: 'Asia/Kolkata',
avatar_url: '',
page_id: null,
widget_color: '#1F93FF',
website_url: 'chatwoot.com',
welcome_title: 'Hi there ! 🙌🏼',
welcome_tagline:
'We make it simple to connect with us. Ask us anything, or share your feedback.',
enable_auto_assignment: true,
website_token: '',
forward_to_email: null,
phone_number: null,
selected_feature_flags: ['attachments', 'emoji_picker'],
reply_time: 'in_a_few_hours',
hmac_token: '',
pre_chat_form_enabled: true,
pre_chat_form_options: {
require_email: true,
pre_chat_message: 'Share your queries or comments here.',
},
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
role: 'administrator',
thumbnail:
'http://0.0.0.0:3000/rails/active_storage/representations/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--bfa5e8a4563aef73980771fc9b8007d380e586e5/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9MY21WemFYcGxTU0lNTWpVd2VESTFNQVk2QmtWVSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--c13bd5229b2a2a692444606e22f76ad61c634661/73185.jpeg',
},
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
{
id: 2,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
greeting_enabled: true,
greeting_message: '',
working_hours_enabled: true,
out_of_office_message:
'We are unavailable at the moment. Leave a message we will respond once we are back.',
working_hours: [
{
day_of_week: 0,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
},
{
day_of_week: 1,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 2,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 3,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 5,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 6,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
},
],
timezone: 'Asia/Kolkata',
avatar_url: '',
page_id: null,
widget_color: '#1F93FF',
website_url: 'chatwoot.com',
welcome_title: 'Hi there ! 🙌🏼',
welcome_tagline:
'We make it simple to connect with us. Ask us anything, or share your feedback.',
enable_auto_assignment: true,
web_widget_script: '',
website_token: '',
forward_to_email: null,
phone_number: null,
selected_feature_flags: ['attachments', 'emoji_picker'],
reply_time: 'in_a_few_hours',
hmac_token: '',
pre_chat_form_enabled: true,
pre_chat_form_options: {
require_email: true,
pre_chat_message: 'Share your queries or comments here.',
},
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
role: 'administrator',
thumbnail:
'http://0.0.0.0:3000/rails/active_storage/representations/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBDZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--bfa5e8a4563aef73980771fc9b8007d380e586e5/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCam9MY21WemFYcGxTU0lNTWpVd2VESTFNQVk2QmtWVSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--c13bd5229b2a2a692444606e22f76ad61c634661/73185.jpeg',
},
message: 'Begin your onboarding campaign with a welcome message',
enabled: true,
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
{
id: 3,
title: 'Thanks',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
greeting_enabled: true,
greeting_message: '',
working_hours_enabled: true,
out_of_office_message:
'We are unavailable at the moment. Leave a message we will respond once we are back.',
working_hours: [
{
day_of_week: 0,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
},
{
day_of_week: 1,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 2,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 3,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 5,
closed_all_day: false,
open_hour: 11,
open_minutes: 0,
close_hour: 23,
close_minutes: 30,
},
{
day_of_week: 6,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
},
],
timezone: 'Asia/Kolkata',
avatar_url: '',
page_id: null,
widget_color: '#1F93FF',
website_url: 'chatwoot.com',
welcome_title: 'Hi there ! 🙌🏼',
welcome_tagline:
'We make it simple to connect with us. Ask us anything, or share your feedback.',
enable_auto_assignment: true,
web_widget_script: '',
website_token: '',
forward_to_email: null,
phone_number: null,
selected_feature_flags: ['attachments', 'emoji_picker'],
reply_time: 'in_a_few_hours',
hmac_token: '',
pre_chat_form_enabled: true,
pre_chat_form_options: {
require_email: true,
pre_chat_message: 'Share your queries or comments here.',
},
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'nithin@chatwoot.com',
available_name: 'Nithin',
id: 13,
name: 'Nithin',
role: 'administrator',
thumbnail: '',
},
message: 'Thanks for coming to the show. How may I help you?',
enabled: false,
trigger_rules: {
url: 'https://noshow.com',
time_on_page: 10,
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
];

View file

@ -0,0 +1,22 @@
import { getters } from '../../campaigns';
import campaigns from './fixtures';
describe('#getters', () => {
it('getCampaigns', () => {
const state = { records: campaigns };
expect(getters.getCampaigns(state)).toEqual(campaigns);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isCreating: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isCreating: false,
});
});
});

View file

@ -0,0 +1,33 @@
import types from '../../../mutation-types';
import { mutations } from '../../campaigns';
import campaigns from './fixtures';
describe('#mutations', () => {
describe('#SET_CAMPAIGNS', () => {
it('set campaigns records', () => {
const state = { records: [] };
mutations[types.SET_CAMPAIGNS](state, campaigns);
expect(state.records).toEqual(campaigns);
});
});
describe('#ADD_CAMPAIGN', () => {
it('push newly created campaigns to the store', () => {
const state = { records: [campaigns[0]] };
mutations[types.ADD_CAMPAIGN](state, campaigns[1]);
expect(state.records).toEqual([campaigns[0], campaigns[1]]);
});
});
describe('#EDIT_CAMPAIGN', () => {
it('update campaign record', () => {
const state = { records: [campaigns[0]] };
mutations[types.EDIT_CAMPAIGN](state, {
id: 1,
title: 'Welcome',
account_id: 1,
message: 'Hey, What brings you today',
});
expect(state.records[0].message).toEqual('Hey, What brings you today');
});
});
});

View file

@ -6,9 +6,13 @@ const { getters } = Contacts;
describe('#getters', () => {
it('getContacts', () => {
const state = {
records: { 1: contactList[0] },
records: { 1: contactList[0], 3: contactList[2] },
sortOrder: [3, 1],
};
expect(getters.getContacts(state)).toEqual([contactList[0]]);
expect(getters.getContacts(state)).toEqual([
contactList[2],
contactList[0],
]);
});
it('getContact', () => {

View file

@ -7,6 +7,7 @@ describe('#mutations', () => {
it('set contact records', () => {
const state = { records: {} };
mutations[types.SET_CONTACTS](state, [
{ id: 2, name: 'contact2', email: 'contact2@chatwoot.com' },
{ id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
]);
expect(state.records).toEqual({
@ -15,7 +16,13 @@ describe('#mutations', () => {
name: 'contact1',
email: 'contact1@chatwoot.com',
},
2: {
id: 2,
name: 'contact2',
email: 'contact2@chatwoot.com',
},
});
expect(state.sortOrder).toEqual([2, 1]);
});
});
@ -25,6 +32,7 @@ describe('#mutations', () => {
records: {
1: { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
},
sortOrder: [1],
};
mutations[types.SET_CONTACT_ITEM](state, {
id: 2,
@ -35,6 +43,7 @@ describe('#mutations', () => {
1: { id: 1, name: 'contact1', email: 'contact1@chatwoot.com' },
2: { id: 2, name: 'contact2', email: 'contact2@chatwoot.com' },
});
expect(state.sortOrder).toEqual([1, 2]);
});
});

View file

@ -145,4 +145,10 @@ export default {
// Conversation Search
SEARCH_CONVERSATIONS_SET: 'SEARCH_CONVERSATIONS_SET',
SEARCH_CONVERSATIONS_SET_UI_FLAG: 'SEARCH_CONVERSATIONS_SET_UI_FLAG',
// Campaigns
SET_CAMPAIGN_UI_FLAG: 'SET_CAMPAIGN_UI_FLAG',
SET_CAMPAIGNS: 'SET_CAMPAIGNS',
ADD_CAMPAIGN: 'ADD_CAMPAIGN',
EDIT_CAMPAIGN: 'EDIT_CAMPAIGN',
};

View file

@ -14,7 +14,7 @@ export default {
computed: {
...mapGetters({ uiSettings: 'getUISettings' }),
isIconTypeEmoji() {
const { icon_type: iconType } = this.uiSettings;
const { icon_type: iconType } = this.uiSettings || {};
return iconType === 'emoji';
},
showEmoji() {

View file

@ -11,7 +11,7 @@ describe('DateSeparator', () => {
$t: () => {},
},
});
expect(wrapper.isVueInstance()).toBeTruthy();
expect(wrapper.vm).toBeTruthy();
expect(wrapper.element).toMatchSnapshot();
});
});

View file

@ -4,7 +4,7 @@ import Spinner from '../Spinner';
describe('Spinner', () => {
test('matches snapshot', () => {
const wrapper = mount(Spinner);
expect(wrapper.isVueInstance()).toBeTruthy();
expect(wrapper.vm).toBeTruthy();
expect(wrapper.element).toMatchSnapshot();
});
});

View file

@ -0,0 +1,19 @@
import { action } from '@storybook/addon-actions';
import AddLabel from './AddLabel';
export default {
title: 'Components/Label/Add Button',
component: AddLabel,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { AddLabel },
template: '<add-label v-bind="$props" @add="onClick"></add-label>',
});
export const AddButton = Template.bind({});
AddButton.args = {
onClick: action('opened'),
};

View file

@ -0,0 +1,25 @@
<template>
<div>
<woot-button
variant="hollow"
size="tiny"
icon="ion-plus-round"
color-scheme="secondary"
@click="toggleLabels"
>
{{ $t('CONTACT_PANEL.LABELS.MODAL.ADD_BUTTON') }}
</woot-button>
</div>
</template>
<script>
export default {
methods: {
toggleLabels() {
this.$emit('add');
},
},
};
</script>
<style lang="scss" scoped></style>

View file

@ -27,19 +27,21 @@ export default {
};
</script>
<style lang="scss" scoped>
.dropdown-menu__item ::v-deep {
a,
.button {
font-weight: var(--font-size-normal);
font-size: var(--font-size-small);
width: 100%;
text-align: left;
white-space: nowrap;
padding: var(--space-small) var(--space-one);
.dropdown-menu__item {
list-style: none;
&:hover {
background: var(--color-background);
border-radius: var(--border-radius-normal);
::v-deep {
a,
.button {
width: 100%;
text-align: left;
white-space: nowrap;
padding: var(--space-small) var(--space-one);
&:hover {
background: var(--color-background);
border-radius: var(--border-radius-normal);
}
}
}
}

View file

@ -0,0 +1,58 @@
import { action } from '@storybook/addon-actions';
import LabelDropdown from './LabelDropdown';
export default {
title: 'Components/Label/Dropdown',
component: LabelDropdown,
argTypes: {
conversationId: {
control: {
type: 'text',
},
},
accountLabels: {
defaultValue: [
{
color: '#555',
description: '',
id: 1,
title: 'sales',
},
{
color: '#c242f5',
description: '',
id: 1,
title: 'business',
},
{
color: '#4287f5',
description: '',
id: 1,
title: 'testing',
},
],
control: {
type: 'object',
},
},
selectedLabels: {
defaultValue: 'sales, testing',
control: {
type: 'object',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LabelDropdown },
template:
'<label-dropdown v-bind="$props" @add="onAdd" @remove="onRemove"></label-dropdown>',
});
export const Dropdown = Template.bind({});
Dropdown.args = {
onAdd: action('added'),
onRemove: action('removed'),
};

View file

@ -0,0 +1,156 @@
<template>
<div class="dropdown-search-wrap">
<h4 class="text-block-title">
{{ $t('CONTACT_PANEL.LABELS.LABEL_SELECT.TITLE') }}
</h4>
<div class="search-wrap">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="$t('CONTACT_PANEL.LABELS.LABEL_SELECT.PLACEHOLDER')"
/>
</div>
<div class="list-wrap">
<div class="list">
<woot-dropdown-menu>
<label-dropdown-item
v-for="label in filteredActiveLabels"
:key="label.title"
:title="label.title"
:color="label.color"
:selected="selectedLabels.includes(label.title)"
@click="onAddRemove(label)"
/>
</woot-dropdown-menu>
<div v-if="noResult" class="no-result">
{{ $t('CONTACT_PANEL.LABELS.LABEL_SELECT.NO_RESULT') }}
</div>
</div>
</div>
</div>
</template>
<script>
import LabelDropdownItem from './LabelDropdownItem';
export default {
components: {
LabelDropdownItem,
},
props: {
conversationId: {
type: [String, Number],
required: true,
},
accountLabels: {
type: Array,
default: () => [],
},
selectedLabels: {
type: Array,
default: () => [],
},
},
data() {
return {
search: '',
};
},
computed: {
filteredActiveLabels() {
return this.accountLabels.filter(label => {
return label.title.toLowerCase().includes(this.search.toLowerCase());
});
},
noResult() {
return this.filteredActiveLabels.length === 0 && this.search !== '';
},
},
mounted() {
this.focusInput();
},
methods: {
focusInput() {
this.$refs.searchbar.focus();
},
updateLabels(label) {
this.$emit('update', label);
},
onAdd(label) {
this.$emit('add', label);
},
onRemove(label) {
this.$emit('remove', label);
},
onAddRemove(label) {
if (this.selectedLabels.includes(label.title)) {
this.onRemove(label.title);
} else {
this.onAdd(label);
}
},
},
};
</script>
<style lang="scss" scoped>
.dropdown-search-wrap {
display: flex;
flex-direction: column;
width: 100%;
max-height: 20rem;
.search-wrap {
margin-bottom: var(--space-small);
flex: 0 0 auto;
max-height: var(--space-large);
.search-input {
margin: 0;
width: 100%;
border: 1px solid transparent;
height: var(--space-large);
font-size: var(--font-size-small);
padding: var(--space-small);
background-color: var(--color-background);
}
input:focus {
border: 1px solid var(--w-500);
}
}
.list-wrap {
display: flex;
justify-content: flex-start;
align-items: flex-start;
flex: 1 1 auto;
overflow: auto;
.list {
width: 100%;
}
.no-result {
display: flex;
justify-content: center;
color: var(--s-700);
padding: var(--space-smaller) var(--space-one);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-small);
}
}
}
</style>

View file

@ -0,0 +1,39 @@
import { action } from '@storybook/addon-actions';
import LabelDropdownItem from './LabelDropdownItem';
export default {
title: 'Components/Label/Item',
component: LabelDropdownItem,
argTypes: {
title: {
defaultValue: 'sales',
control: {
type: 'text',
},
},
color: {
defaultValue: '#555',
control: {
type: 'text',
},
},
selected: {
defaultValue: true,
control: {
type: 'boolean',
},
},
},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { LabelDropdownItem },
template:
'<label-dropdown-item v-bind="$props" @click="onClick"></label-dropdown-item>',
});
export const item = Template.bind({});
item.args = {
onClick: action('Selected'),
};

View file

@ -0,0 +1,82 @@
<template>
<woot-dropdown-item>
<div class="item-wrap">
<woot-button variant="clear" @click="onClick">
<div class="button-wrap">
<div class="name-label-wrap">
<div
v-if="color"
class="label-color--display"
:style="{ backgroundColor: color }"
/>
<span>{{ title }}</span>
</div>
<i v-if="selected" class="icon ion-checkmark-round" />
</div>
</woot-button>
</div>
</woot-dropdown-item>
</template>
<script>
export default {
props: {
title: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
selected: {
type: Boolean,
default: false,
},
},
methods: {
onClick() {
this.$emit('click', this.title);
},
},
};
</script>
<style lang="scss" scoped>
.item-wrap {
display: flex;
.button-wrap {
display: flex;
justify-content: space-between;
&.active {
display: flex;
font-weight: var(--font-weight-bold);
color: var(--w-700);
}
.name-label-wrap {
display: flex;
}
.label-color--display {
margin-right: var(--space-small);
}
.icon {
font-size: var(--font-size-small);
}
}
.label-color--display {
border-radius: var(--border-radius-normal);
height: var(--space-slab);
margin-right: var(--space-smaller);
margin-top: var(--space-micro);
min-width: var(--space-slab);
width: var(--space-slab);
}
}
</style>

View file

@ -6,5 +6,6 @@ export const MESSAGE_MAX_LENGTH = {
GENERAL: 10000,
FACEBOOK: 640,
TWILIO_SMS: 160,
TWILIO_WHATSAPP: 1600,
TWEET: 280,
};

View file

@ -73,6 +73,7 @@ export default {
methods: {
...mapActions('appConfig', ['setWidgetColor']),
...mapActions('conversation', ['fetchOldConversations', 'setUserLastSeen']),
...mapActions('campaign', ['fetchCampaigns']),
...mapActions('agent', ['fetchAvailableAgents']),
scrollConversationToBottom() {
const container = this.$el.querySelector('.conversation-wrap');
@ -149,6 +150,7 @@ export default {
this.fetchOldConversations().then(() => this.setUnreadView());
this.setPopoutDisplay(message.showPopoutButton);
this.fetchAvailableAgents(websiteToken);
this.fetchCampaigns(websiteToken);
this.setHideMessageBubble(message.hideMessageBubble);
this.$store.dispatch('contacts/get');
} else if (message.event === 'widget-visible') {

View file

@ -0,0 +1,23 @@
import endPoints from 'widget/api/endPoints';
import { API } from 'widget/helpers/axios';
const getCampaigns = async websiteToken => {
const urlData = endPoints.getCampaigns(websiteToken);
const result = await API.get(urlData.url, { params: urlData.params });
return result;
};
const triggerCampaign = async ({ campaignId }) => {
const { websiteToken } = window.chatwootWebChannel;
const urlData = endPoints.triggerCampaign(websiteToken, campaignId);
await API.post(
urlData.url,
{ ...urlData.data },
{
params: urlData.params,
}
);
};
export { getCampaigns, triggerCampaign };

View file

@ -64,6 +64,24 @@ const getAvailableAgents = token => ({
website_token: token,
},
});
const getCampaigns = token => ({
url: '/api/v1/widget/campaigns',
params: {
website_token: token,
},
});
const triggerCampaign = (token, campaignId) => ({
url: '/api/v1/widget/events',
data: {
name: 'campaign.triggered',
event_info: {
campaign_id: campaignId,
},
},
params: {
website_token: token,
},
});
export default {
createConversation,
@ -72,4 +90,6 @@ export default {
getConversation,
updateMessage,
getAvailableAgents,
getCampaigns,
triggerCampaign,
};

View file

@ -4,7 +4,7 @@
:href="brandRedirectURL"
rel="noreferrer noopener nofollow"
target="_blank"
class="branding--link"
class="branding--link w-full justify-center"
>
<img :alt="globalConfig.brandName" :src="globalConfig.logoThumbnail" />
<span>

View file

@ -0,0 +1,14 @@
import { triggerCampaign } from 'widget/api/campaign';
const startTimer = async ({ allCampaigns }) => {
allCampaigns.forEach(campaign => {
const {
trigger_rules: { time_on_page: timeOnPage },
id: campaignId,
} = campaign;
setTimeout(async () => {
await triggerCampaign({ campaignId });
}, timeOnPage * 1000);
});
};
export { startTimer };

View file

@ -9,9 +9,9 @@ import conversationLabels from 'widget/store/modules/conversationLabels';
import events from 'widget/store/modules/events';
import globalConfig from 'shared/store/globalConfig';
import message from 'widget/store/modules/message';
import campaign from 'widget/store/modules/campaign';
Vue.use(Vuex);
export default new Vuex.Store({
modules: {
agent,
@ -23,5 +23,6 @@ export default new Vuex.Store({
events,
globalConfig,
message,
campaign,
},
});

View file

@ -0,0 +1,51 @@
import Vue from 'vue';
import { getCampaigns } from 'widget/api/campaign';
import { startTimer } from 'widget/helpers/campaignTimer';
const state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
},
};
export const getters = {
getHasFetched: $state => $state.uiFlags.hasFetched,
fetchCampaigns: $state => $state.records,
};
export const actions = {
fetchCampaigns: async ({ commit }, websiteToken) => {
try {
const { data } = await getCampaigns(websiteToken);
startTimer({ allCampaigns: data });
commit('setCampaigns', data);
commit('setError', false);
commit('setHasFetched', true);
} catch (error) {
commit('setError', true);
commit('setHasFetched', true);
}
},
};
export const mutations = {
setCampaigns($state, data) {
Vue.set($state, 'records', data);
},
setError($state, value) {
Vue.set($state.uiFlags, 'isError', value);
},
setHasFetched($state, value) {
Vue.set($state.uiFlags, 'hasFetched', value);
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View file

@ -0,0 +1,28 @@
import { API } from 'widget/helpers/axios';
import { actions } from '../../campaign';
import { campaigns } from './data';
const commit = jest.fn();
jest.mock('widget/helpers/axios');
describe('#actions', () => {
describe('#fetchCampaigns', () => {
it('sends correct actions if API is success', async () => {
API.get.mockResolvedValue({ data: campaigns });
await actions.fetchCampaigns({ commit }, 'XDsafmADasd');
expect(commit.mock.calls).toEqual([
['setCampaigns', campaigns],
['setError', false],
['setHasFetched', true],
]);
});
it('sends correct actions if API is error', async () => {
API.get.mockRejectedValue({ message: 'Authentication required' });
await actions.fetchCampaigns({ commit }, 'XDsafmADasd');
expect(commit.mock.calls).toEqual([
['setError', true],
['setHasFetched', true],
]);
});
});
});

View file

@ -0,0 +1,86 @@
export const campaigns = [
{
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
},
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
{
id: 11,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'GitX',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
},
message: 'Begin your onboarding campaign with a welcome message',
enabled: true,
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
{
id: 12,
title: 'Thanks',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'nithin@chatwoot.com',
available_name: 'Nithin',
},
message: 'Thanks for coming to the show. How may I help you?',
enabled: false,
trigger_rules: {
url: 'https://noshow.com',
time_on_page: 10,
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
];

View file

@ -0,0 +1,96 @@
import { getters } from '../../campaign';
import { campaigns } from './data';
describe('#getters', () => {
it('fetchCampaigns', () => {
const state = {
records: campaigns,
};
expect(getters.fetchCampaigns(state)).toEqual([
{
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
},
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
{
id: 11,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'GitX',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
},
message: 'Begin your onboarding campaign with a welcome message',
enabled: true,
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
{
id: 12,
title: 'Thanks',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'nithin@chatwoot.com',
available_name: 'Nithin',
},
message: 'Thanks for coming to the show. How may I help you?',
enabled: false,
trigger_rules: {
url: 'https://noshow.com',
time_on_page: 10,
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
]);
});
});

View file

@ -0,0 +1,28 @@
import { mutations } from '../../campaign';
import { campaigns } from './data';
describe('#mutations', () => {
describe('#setCampagins', () => {
it('set campaign records', () => {
const state = { records: [] };
mutations.setCampaigns(state, campaigns);
expect(state.records).toEqual(campaigns);
});
});
describe('#setError', () => {
it('set error flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setError(state, true);
expect(state.uiFlags.isError).toEqual(true);
});
});
describe('#setHasFetched', () => {
it('set fetched flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setHasFetched(state, true);
expect(state.uiFlags.hasFetched).toEqual(true);
});
});
});

View file

@ -1,11 +1,29 @@
class HookJob < ApplicationJob
queue_as :integrations
def perform(hook, message)
return unless hook.slack?
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
def perform(hook, event_name, event_data = {})
case hook.app_id
when 'slack'
process_slack_integration(hook, event_name, event_data)
when 'dialogflow'
process_dialogflow_integration(hook, event_name, event_data)
end
rescue StandardError => e
Raven.capture_exception(e)
end
private
def process_slack_integration(hook, event_name, event_data)
return unless ['message.created'].include?(event_name)
message = event_data[:message]
Integrations::Slack::SendOnSlackService.new(message: message, hook: hook).perform
end
def process_dialogflow_integration(hook, event_name, event_data)
return unless ['message.created', 'message.updated'].include?(event_name)
Integrations::Dialogflow::ProcessorService.new(event_name: event_name, hook: hook, event_data: event_data).perform
end
end

View file

@ -0,0 +1,14 @@
class CampaignListener < BaseListener
def campaign_triggered(event)
contact_inbox = event.data[:contact_inbox]
campaign_display_id = event.data[:event_info][:campaign_id]
return if campaign_display_id.blank?
::Campaigns::CampaignConversationBuilder.new(
contact_inbox_id: contact_inbox.id,
campaign_display_id: campaign_display_id,
conversation_additional_attributes: event.data[:event_info].except(:campaign_id)
).perform
end
end

View file

@ -4,7 +4,16 @@ class HookListener < BaseListener
return unless message.reportable?
message.account.hooks.each do |hook|
HookJob.perform_later(hook, message)
HookJob.perform_later(hook, event.name, message: message)
end
end
def message_updated(event)
message = extract_message_and_account(event)[0]
return unless message.reportable?
message.account.hooks.each do |hook|
HookJob.perform_later(hook, event.name, message: message)
end
end
end

View file

@ -59,7 +59,9 @@ class WebhookListener < BaseListener
WebhookJob.perform_later(webhook.url, payload)
end
# Deliver for API Inbox
WebhookJob.perform_later(inbox.channel.webhook_url, payload) if inbox.channel_type == 'Channel::Api'
return unless inbox.channel_type == 'Channel::Api'
return if inbox.channel.webhook_url.blank?
WebhookJob.perform_later(inbox.channel.webhook_url, payload)
end
end

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