diff --git a/Gemfile.lock b/Gemfile.lock
index a16b50a98..2222403f1 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -9,63 +9,63 @@ GIT
GEM
remote: https://rubygems.org/
specs:
- actioncable (6.1.4.6)
- actionpack (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ actioncable (6.1.4.7)
+ actionpack (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
- actionmailbox (6.1.4.6)
- actionpack (= 6.1.4.6)
- activejob (= 6.1.4.6)
- activerecord (= 6.1.4.6)
- activestorage (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ actionmailbox (6.1.4.7)
+ actionpack (= 6.1.4.7)
+ activejob (= 6.1.4.7)
+ activerecord (= 6.1.4.7)
+ activestorage (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
mail (>= 2.7.1)
- actionmailer (6.1.4.6)
- actionpack (= 6.1.4.6)
- actionview (= 6.1.4.6)
- activejob (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ actionmailer (6.1.4.7)
+ actionpack (= 6.1.4.7)
+ actionview (= 6.1.4.7)
+ activejob (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
- actionpack (6.1.4.6)
- actionview (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ actionpack (6.1.4.7)
+ actionview (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
- actiontext (6.1.4.6)
- actionpack (= 6.1.4.6)
- activerecord (= 6.1.4.6)
- activestorage (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ actiontext (6.1.4.7)
+ actionpack (= 6.1.4.7)
+ activerecord (= 6.1.4.7)
+ activestorage (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
nokogiri (>= 1.8.5)
- actionview (6.1.4.6)
- activesupport (= 6.1.4.6)
+ actionview (6.1.4.7)
+ activesupport (= 6.1.4.7)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
- activejob (6.1.4.6)
- activesupport (= 6.1.4.6)
+ activejob (6.1.4.7)
+ activesupport (= 6.1.4.7)
globalid (>= 0.3.6)
- activemodel (6.1.4.6)
- activesupport (= 6.1.4.6)
- activerecord (6.1.4.6)
- activemodel (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ activemodel (6.1.4.7)
+ activesupport (= 6.1.4.7)
+ activerecord (6.1.4.7)
+ activemodel (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
activerecord-import (1.3.0)
activerecord (>= 4.2)
- activestorage (6.1.4.6)
- actionpack (= 6.1.4.6)
- activejob (= 6.1.4.6)
- activerecord (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ activestorage (6.1.4.7)
+ actionpack (= 6.1.4.7)
+ activejob (= 6.1.4.7)
+ activerecord (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
marcel (~> 1.0.0)
mini_mime (>= 1.1.0)
- activesupport (6.1.4.6)
+ activesupport (6.1.4.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@@ -419,29 +419,29 @@ GEM
rack-test (1.1.0)
rack (>= 1.0, < 3)
rack-timeout (0.6.0)
- rails (6.1.4.6)
- actioncable (= 6.1.4.6)
- actionmailbox (= 6.1.4.6)
- actionmailer (= 6.1.4.6)
- actionpack (= 6.1.4.6)
- actiontext (= 6.1.4.6)
- actionview (= 6.1.4.6)
- activejob (= 6.1.4.6)
- activemodel (= 6.1.4.6)
- activerecord (= 6.1.4.6)
- activestorage (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ rails (6.1.4.7)
+ actioncable (= 6.1.4.7)
+ actionmailbox (= 6.1.4.7)
+ actionmailer (= 6.1.4.7)
+ actionpack (= 6.1.4.7)
+ actiontext (= 6.1.4.7)
+ actionview (= 6.1.4.7)
+ activejob (= 6.1.4.7)
+ activemodel (= 6.1.4.7)
+ activerecord (= 6.1.4.7)
+ activestorage (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
bundler (>= 1.15.0)
- railties (= 6.1.4.6)
+ railties (= 6.1.4.7)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
loofah (~> 2.3)
- railties (6.1.4.6)
- actionpack (= 6.1.4.6)
- activesupport (= 6.1.4.6)
+ railties (6.1.4.7)
+ actionpack (= 6.1.4.7)
+ activesupport (= 6.1.4.7)
method_source
rake (>= 0.13)
thor (~> 1.0)
@@ -574,7 +574,7 @@ GEM
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
spring (>= 1.2, < 3.0)
- sprockets (4.0.2)
+ sprockets (4.0.3)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
@@ -751,4 +751,4 @@ RUBY VERSION
ruby 3.0.2p107
BUNDLED WITH
- 2.2.25
+ 2.3.8
diff --git a/app/builders/messages/messenger/message_builder.rb b/app/builders/messages/messenger/message_builder.rb
index 08aa58be0..2e58825f4 100644
--- a/app/builders/messages/messenger/message_builder.rb
+++ b/app/builders/messages/messenger/message_builder.rb
@@ -1,10 +1,14 @@
class Messages::Messenger::MessageBuilder
+ include ::FileTypeHelper
+
def process_attachment(attachment)
return if attachment['type'].to_sym == :template
attachment_obj = @message.attachments.new(attachment_params(attachment).except(:remote_file_url))
attachment_obj.save!
attach_file(attachment_obj, attachment_params(attachment)[:remote_file_url]) if attachment_params(attachment)[:remote_file_url]
+ fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
+ update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -22,7 +26,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
- if [:image, :file, :audio, :video].include? file_type
+ if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -39,4 +43,31 @@ class Messages::Messenger::MessageBuilder
remote_file_url: attachment['payload']['url']
}
end
+
+ def update_attachment_file_type(attachment)
+ return unless attachment.file_type == 'share' || attachment.file_type == 'story_mention'
+
+ attachment.file_type = file_type(attachment.file&.content_type)
+ attachment.save!
+ end
+
+ def fetch_story_link(attachment)
+ message = attachment.message
+ begin
+ k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
+ result = k.get_object(message.source_id, fields: %w[story from]) || {}
+ rescue Koala::Facebook::AuthenticationError
+ @inbox.channel.authorization_error!
+ raise
+ rescue StandardError => e
+ result = {}
+ Sentry.capture_exception(e)
+ end
+ story_id = result['story']['mention']['id']
+ story_sender = result['from']['username']
+ message.content_attributes[:story_sender] = story_sender
+ message.content_attributes[:story_id] = story_id
+ message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
+ message.save!
+ end
end
diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb
index 2bda5c07a..d60e4c3e8 100644
--- a/app/controllers/api/v1/accounts/inboxes_controller.rb
+++ b/app/controllers/api/v1/accounts/inboxes_controller.rb
@@ -49,7 +49,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
return if permitted_params(channel_attributes)[:channel].blank?
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
-
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
update_channel_feature_flags
end
diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb
index 38c880526..ad555fc22 100644
--- a/app/controllers/api/v1/widget/base_controller.rb
+++ b/app/controllers/api/v1/widget/base_controller.rb
@@ -68,7 +68,7 @@ class Api::V1::Widget::BaseController < ApplicationController
mergee_contact: @contact
).perform
else
- @contact.update!(email: email, name: contact_name)
+ @contact.update!(email: email, name: contact_name, phone_number: contact_phone_number)
end
end
@@ -80,6 +80,10 @@ class Api::V1::Widget::BaseController < ApplicationController
params[:contact][:name] || contact_email.split('@')[0]
end
+ def contact_phone_number
+ params[:contact][:phone_number]
+ end
+
def browser_params
{
browser_name: browser.name,
diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb
index 8d28345d4..8cfb65be6 100644
--- a/app/controllers/api/v1/widget/conversations_controller.rb
+++ b/app/controllers/api/v1/widget/conversations_controller.rb
@@ -51,6 +51,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
end
def permitted_params
- params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
+ params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
+ message: [:content, :referer_url, :timestamp, :echo_id])
end
end
diff --git a/app/controllers/api/v2/accounts/reports_controller.rb b/app/controllers/api/v2/accounts/reports_controller.rb
index a83611676..efa09a43c 100644
--- a/app/controllers/api/v2/accounts/reports_controller.rb
+++ b/app/controllers/api/v2/accounts/reports_controller.rb
@@ -41,12 +41,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
- def summary_params
+ def current_summary_params
{
type: params[:type].to_sym,
- since: params[:since],
- until: params[:until],
id: params[:id],
+ since: range[:current][:since],
+ until: range[:current][:until],
+ group_by: params[:group_by]
+ }
+ end
+
+ def previous_summary_params
+ {
+ type: params[:type].to_sym,
+ id: params[:id],
+ since: range[:previous][:since],
+ until: range[:previous][:until],
group_by: params[:group_by]
}
end
@@ -63,8 +73,22 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
}
end
+ def range
+ {
+ current: {
+ since: params[:since],
+ until: params[:until]
+ },
+ previous: {
+ since: (params[:since].to_i - (params[:until].to_i - params[:since].to_i)).to_s,
+ until: params[:since]
+ }
+ }
+ end
+
def summary_metrics
- builder = V2::ReportBuilder.new(Current.account, summary_params)
- builder.summary
+ summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
+ summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
+ summary
end
end
diff --git a/app/helpers/file_type_helper.rb b/app/helpers/file_type_helper.rb
index 9e822b6c4..db3f6249d 100644
--- a/app/helpers/file_type_helper.rb
+++ b/app/helpers/file_type_helper.rb
@@ -14,7 +14,8 @@ module FileTypeHelper
'image/png',
'image/gif',
'image/bmp',
- 'image/webp'
+ 'image/webp',
+ 'image'
].include?(content_type)
end
@@ -23,7 +24,8 @@ module FileTypeHelper
'video/ogg',
'video/mp4',
'video/webm',
- 'video/quicktime'
+ 'video/quicktime',
+ 'video'
].include?(content_type)
end
end
diff --git a/app/javascript/dashboard/assets/scss/widgets/_report.scss b/app/javascript/dashboard/assets/scss/widgets/_report.scss
index c62eba70e..6e8b2fe5c 100644
--- a/app/javascript/dashboard/assets/scss/widgets/_report.scss
+++ b/app/javascript/dashboard/assets/scss/widgets/_report.scss
@@ -20,12 +20,30 @@
color: $color-heading;
}
+ .metric-wrap {
+ align-items: baseline;
+ display: flex;
+ }
+
.metric {
font-size: $font-size-big;
font-weight: $font-weight-feather;
margin-top: $space-smaller;
}
+ .metric-trend {
+ font-size: $font-size-small;
+ margin-left: $space-small;
+ }
+
+ .metric-up {
+ color: $success-color;
+ }
+
+ .metric-down {
+ color: $alert-color;
+ }
+
.desc {
@include margin($zero);
font-size: $font-size-small;
diff --git a/app/javascript/dashboard/components/buttons/ToggleButton.vue b/app/javascript/dashboard/components/buttons/ToggleButton.vue
new file mode 100644
index 000000000..37328aba5
--- /dev/null
+++ b/app/javascript/dashboard/components/buttons/ToggleButton.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components/layout/Sidebar.vue b/app/javascript/dashboard/components/layout/Sidebar.vue
index ee64f4949..cdd338710 100644
--- a/app/javascript/dashboard/components/layout/Sidebar.vue
+++ b/app/javascript/dashboard/components/layout/Sidebar.vue
@@ -19,23 +19,6 @@
:current-role="currentRole"
@add-label="showAddLabelPopup"
/>
-
-
-
-
-
-
@@ -46,12 +29,8 @@ import adminMixin from '../../mixins/isAdmin';
import { getSidebarItems } from './config/default-sidebar';
import alertMixin from 'shared/mixins/alertMixin';
-import AccountSelector from './sidebarComponents/AccountSelector.vue';
-import AddAccountModal from './sidebarComponents/AddAccountModal.vue';
-import AddLabelModal from '../../routes/dashboard/settings/labels/AddLabel';
import PrimarySidebar from './sidebarComponents/Primary';
import SecondarySidebar from './sidebarComponents/Secondary';
-import WootKeyShortcutModal from 'components/widgets/modal/WootKeyShortcutModal';
import {
hasPressedAltAndCKey,
hasPressedAltAndRKey,
@@ -65,21 +44,13 @@ import router from '../../routes';
export default {
components: {
- AccountSelector,
- AddAccountModal,
- AddLabelModal,
PrimarySidebar,
SecondarySidebar,
- WootKeyShortcutModal,
},
mixins: [adminMixin, alertMixin, eventListenerMixins],
data() {
return {
showOptionsMenu: false,
- showAccountModal: false,
- showCreateAccountModal: false,
- showAddLabelModal: false,
- showShortcutModal: false,
};
},
@@ -162,10 +133,10 @@ export default {
}
},
toggleKeyShortcutModal() {
- this.showShortcutModal = true;
+ this.$emit('open-key-shortcut-modal');
},
closeKeyShortcutModal() {
- this.showShortcutModal = false;
+ this.$emit('close-key-shortcut-modal');
},
handleKeyEvents(e) {
if (hasPressedCommandAndForwardSlash(e)) {
@@ -200,20 +171,10 @@ export default {
window.$chatwoot.toggle();
},
toggleAccountModal() {
- this.showAccountModal = !this.showAccountModal;
- },
- openCreateAccountModal() {
- this.showAccountModal = false;
- this.showCreateAccountModal = true;
- },
- closeCreateAccountModal() {
- this.showCreateAccountModal = false;
+ this.$emit('toggle-account-modal');
},
showAddLabelPopup() {
- this.showAddLabelModal = true;
- },
- hideAddLabelPopup() {
- this.showAddLabelModal = false;
+ this.$emit('show-add-label-popup');
},
},
};
diff --git a/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js b/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js
index 9e9e3a844..07792c5d5 100644
--- a/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js
+++ b/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js
@@ -69,7 +69,22 @@ export const OPERATOR_TYPES_4 = [
label: 'Is greater than',
},
{
- value: 'is_lesser_than',
- label: 'Is lesser than',
+ value: 'is_less_than',
+ label: 'Is less than',
+ },
+];
+
+export const OPERATOR_TYPES_5 = [
+ {
+ value: 'is_greater_than',
+ label: 'Is greater than',
+ },
+ {
+ value: 'is_less_than',
+ label: 'Is less than',
+ },
+ {
+ value: 'days_before',
+ label: 'Is x days before',
},
];
diff --git a/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.spec.js b/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.spec.js
new file mode 100644
index 000000000..4e8e57b16
--- /dev/null
+++ b/app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.spec.js
@@ -0,0 +1,15 @@
+import {
+ OPERATOR_TYPES_1,
+ OPERATOR_TYPES_2,
+ OPERATOR_TYPES_3,
+ OPERATOR_TYPES_4,
+} from './FilterOperatorTypes';
+
+describe('#filterOperators', () => {
+ it('Matches the correct Operators', () => {
+ expect(OPERATOR_TYPES_1).toMatchObject(OPERATOR_TYPES_1);
+ expect(OPERATOR_TYPES_2).toMatchObject(OPERATOR_TYPES_2);
+ expect(OPERATOR_TYPES_3).toMatchObject(OPERATOR_TYPES_3);
+ expect(OPERATOR_TYPES_4).toMatchObject(OPERATOR_TYPES_4);
+ });
+});
diff --git a/app/javascript/dashboard/components/widgets/FilterInput/Index.vue b/app/javascript/dashboard/components/widgets/FilterInput/Index.vue
index d26467ab6..25203642a 100644
--- a/app/javascript/dashboard/components/widgets/FilterInput/Index.vue
+++ b/app/javascript/dashboard/components/widgets/FilterInput/Index.vue
@@ -141,6 +141,10 @@ export default {
type: String,
default: 'plain_text',
},
+ dataType: {
+ type: String,
+ default: 'plain_text',
+ },
operators: {
type: Array,
default: () => [],
diff --git a/app/javascript/dashboard/components/widgets/ReportStatsCard.vue b/app/javascript/dashboard/components/widgets/ReportStatsCard.vue
index 309627220..9bc46d021 100644
--- a/app/javascript/dashboard/components/widgets/ReportStatsCard.vue
+++ b/app/javascript/dashboard/components/widgets/ReportStatsCard.vue
@@ -7,9 +7,12 @@
@@ -64,6 +65,7 @@
class="message--unread"
:data="message"
:is-a-tweet="isATweet"
+ :has-instagram-story="hasInstagramStory"
/>
{
+ it('Matches the correct filterItems', () => {
+ expect(defaultFilters).toMatchObject(defaultFilters);
+ expect(filterAttributeGroups).toMatchObject(filterAttributeGroups);
+ });
+});
diff --git a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js
index 98e27c5bb..a3e8e2c10 100644
--- a/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js
+++ b/app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js
@@ -2,6 +2,7 @@ import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_2,
OPERATOR_TYPES_3,
+ OPERATOR_TYPES_5,
} from '../../FilterInput/FilterOperatorTypes';
const filterTypes = [
@@ -85,6 +86,30 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attributeModel: 'additional',
},
+ {
+ attributeKey: 'created_at',
+ attributeI18nKey: 'CREATED_AT',
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_5,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'last_activity_at',
+ attributeI18nKey: 'LAST_ACTIVITY',
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_5,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'referer',
+ attributeI18nKey: 'REFERER_LINK',
+ inputType: 'plain_text',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_5,
+ attributeModel: 'standard',
+ },
];
export const filterAttributeGroups = [
@@ -120,6 +145,14 @@ export const filterAttributeGroups = [
key: 'labels',
i18nKey: 'LABELS',
},
+ {
+ key: 'created_at',
+ i18nKey: 'CREATED_AT',
+ },
+ {
+ key: 'last_activity_at',
+ i18nKey: 'LAST_ACTIVITY',
+ },
],
},
{
diff --git a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue
index 957402f78..a20f3d34c 100644
--- a/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/bubble/Actions.vue
@@ -35,6 +35,19 @@
size="16"
/>
+
+
+
{
+ if (!this.accountSummary.previous[metric_key]) return 0;
+ return Math.round(
+ ((this.accountSummary[metric_key] -
+ this.accountSummary.previous[metric_key]) /
+ this.accountSummary.previous[metric_key]) *
+ 100
+ );
+ };
+ },
+ displayMetric() {
+ return metric_key => {
+ if (
+ ['avg_first_response_time', 'avg_resolution_time'].includes(
+ metric_key
+ )
+ ) {
+ return formatTime(this.accountSummary[metric_key]);
+ }
+ return this.accountSummary[metric_key];
+ };
+ },
+ },
+};
diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js
new file mode 100644
index 000000000..003295f95
--- /dev/null
+++ b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js
@@ -0,0 +1,41 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import reportMixin from '../reportMixin';
+import reportFixtures from './reportMixinFixtures';
+import Vuex from 'vuex';
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('reportMixin', () => {
+ let getters;
+ let store;
+ beforeEach(() => {
+ getters = {
+ getAccountSummary: () => reportFixtures.summary,
+ };
+ store = new Vuex.Store({ getters });
+ });
+
+ it('display the metric', () => {
+ const Component = {
+ render() {},
+ title: 'TestComponent',
+ mixins: [reportMixin],
+ };
+ const wrapper = shallowMount(Component, { store, localVue });
+ expect(wrapper.vm.displayMetric('conversations_count')).toEqual(5);
+ expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
+ '3 Min'
+ );
+ });
+
+ it('calculate the trend', () => {
+ const Component = {
+ render() {},
+ title: 'TestComponent',
+ mixins: [reportMixin],
+ };
+ const wrapper = shallowMount(Component, { store, localVue });
+ expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(25);
+ expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
+ });
+});
diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js
new file mode 100644
index 000000000..5c8315ab1
--- /dev/null
+++ b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js
@@ -0,0 +1,18 @@
+export default {
+ summary: {
+ avg_first_response_time: '198.6666666666667',
+ avg_resolution_time: '208.3333333333333',
+ conversations_count: 5,
+ incoming_messages_count: 5,
+ outgoing_messages_count: 3,
+ previous: {
+ avg_first_response_time: '89.0',
+ avg_resolution_time: '145.0',
+ conversations_count: 4,
+ incoming_messages_count: 5,
+ outgoing_messages_count: 4,
+ resolutions_count: 0,
+ },
+ resolutions_count: 3,
+ },
+};
diff --git a/app/javascript/dashboard/routes/dashboard/Dashboard.vue b/app/javascript/dashboard/routes/dashboard/Dashboard.vue
index 670c93d5d..11903c9ed 100644
--- a/app/javascript/dashboard/routes/dashboard/Dashboard.vue
+++ b/app/javascript/dashboard/routes/dashboard/Dashboard.vue
@@ -1,9 +1,33 @@
@@ -12,16 +36,28 @@
import Sidebar from '../../components/layout/Sidebar';
import CommandBar from './commands/commandbar.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
+import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShortcutModal';
+import AddAccountModal from 'dashboard/components/layout/sidebarComponents/AddAccountModal';
+import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector';
+import AddLabelModal from 'dashboard/routes/dashboard/settings/labels/AddLabel.vue';
export default {
components: {
Sidebar,
CommandBar,
+ WootKeyShortcutModal,
+ AddAccountModal,
+ AccountSelector,
+ AddLabelModal,
},
data() {
return {
isSidebarOpen: false,
isOnDesktop: true,
+ showAccountModal: false,
+ showCreateAccountModal: false,
+ showAddLabelModal: false,
+ showShortcutModal: false,
};
},
computed: {
@@ -68,6 +104,28 @@ export default {
toggleSidebar() {
this.isSidebarOpen = !this.isSidebarOpen;
},
+ openCreateAccountModal() {
+ this.showAccountModal = false;
+ this.showCreateAccountModal = true;
+ },
+ closeCreateAccountModal() {
+ this.showCreateAccountModal = false;
+ },
+ toggleAccountModal() {
+ this.showAccountModal = !this.showAccountModal;
+ },
+ toggleKeyShortcutModal() {
+ this.showShortcutModal = true;
+ },
+ closeKeyShortcutModal() {
+ this.showShortcutModal = false;
+ },
+ showAddLabelPopup() {
+ this.showAddLabelModal = true;
+ },
+ hideAddLabelPopup() {
+ this.showAddLabelModal = false;
+ },
},
};
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue
index 3f5162292..95c9ae61c 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue
+++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsAdvancedFilters.vue
@@ -11,7 +11,12 @@
v-model="appliedFilters[i]"
:filter-groups="filterGroups"
:grouped-filters="true"
- :input-type="getInputType(appliedFilters[i].attribute_key)"
+ :input-type="
+ getInputType(
+ appliedFilters[i].attribute_key,
+ appliedFilters[i].filter_operator
+ )
+ "
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
@@ -86,6 +91,12 @@ export default {
$each: {
values: {
required,
+ ensureBetween0to999(value, prop) {
+ if (prop.filter_operator === 'days_before') {
+ return parseInt(value, 10) > 0 && parseInt(value, 10) < 999;
+ }
+ return true;
+ },
},
},
},
@@ -161,7 +172,9 @@ export default {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
- getInputType(key) {
+ getInputType(key, operator) {
+ if (key === 'created_at' || key === 'last_activity_at')
+ if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.inputType;
},
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue
index 7536c0d24..dbac2f0bc 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue
+++ b/app/javascript/dashboard/routes/dashboard/contacts/components/ContactsView.vue
@@ -253,7 +253,7 @@ export default {
this.$store.dispatch('contacts/get', requestParams);
} else {
this.$store.dispatch('contacts/search', {
- search: value,
+ search: encodeURIComponent(value),
...requestParams,
});
}
diff --git a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js
index dc12d2640..2d54c37bc 100644
--- a/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js
+++ b/app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js
@@ -1,6 +1,7 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_3,
+ OPERATOR_TYPES_5,
} from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
const filterTypes = [
{
@@ -51,6 +52,30 @@ const filterTypes = [
filterOperators: OPERATOR_TYPES_3,
attribute_type: 'standard',
},
+ {
+ attributeKey: 'created_at',
+ attributeI18nKey: 'CREATED_AT',
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_5,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'last_activity_at',
+ attributeI18nKey: 'LAST_ACTIVITY',
+ inputType: 'date',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_5,
+ attributeModel: 'standard',
+ },
+ {
+ attributeKey: 'referer',
+ attributeI18nKey: 'REFERER_LINK',
+ inputType: 'plain_text',
+ dataType: 'text',
+ filterOperators: OPERATOR_TYPES_5,
+ attributeModel: 'standard',
+ },
];
export const filterAttributeGroups = [
@@ -82,6 +107,14 @@ export const filterAttributeGroups = [
key: 'city',
i18nKey: 'CITY',
},
+ {
+ key: 'created_at',
+ i18nKey: 'CREATED_AT',
+ },
+ {
+ key: 'last_activity_at',
+ i18nKey: 'LAST_ACTIVITY',
+ },
],
},
];
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
index 90aeb43ee..24aeeff88 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
+++ b/app/javascript/dashboard/routes/dashboard/conversation/contact/ContactInfo.vue
@@ -126,17 +126,15 @@
@close="toggleMergeModal"
/>
-
@@ -221,22 +219,11 @@ export default {
return { twitter: twitterScreenName, ...(socialProfiles || {}) };
},
// Delete Modal
- deleteConfirmText() {
- return `${this.$t('DELETE_CONTACT.CONFIRM.YES')} ${this.contact.name}`;
- },
- deleteRejectText() {
- return `${this.$t('DELETE_CONTACT.CONFIRM.NO')} ${this.contact.name}`;
- },
confirmDeleteMessage() {
return `${this.$t('DELETE_CONTACT.CONFIRM.MESSAGE')} ${
this.contact.name
} ?`;
},
- confirmPlaceHolderText() {
- return `${this.$t('DELETE_CONTACT.CONFIRM.PLACE_HOLDER', {
- contactName: this.contact.name,
- })}`;
- },
},
methods: {
toggleMergeModal() {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue
index 811fc95c6..b3a4ce9d7 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/Index.vue
@@ -34,19 +34,10 @@
{{ automation.name }} |
{{ automation.description }} |
-
+ />
|
{{ readableTime(automation.created_on) }} |
@@ -140,11 +131,13 @@ import AddAutomationRule from './AddAutomationRule.vue';
import EditAutomationRule from './EditAutomationRule.vue';
import alertMixin from 'shared/mixins/alertMixin';
import timeMixin from 'dashboard/mixins/time';
+import ToggleButton from 'dashboard/components/buttons/ToggleButton';
export default {
components: {
AddAutomationRule,
EditAutomationRule,
+ ToggleButton,
},
mixins: [alertMixin, timeMixin],
data() {
@@ -290,41 +283,4 @@ export default {
.automation__status-checkbox {
margin: 0;
}
-.toggle-button {
- background-color: var(--s-200);
- position: relative;
- display: inline-flex;
- height: 19px;
- width: 34px;
- border: 2px solid transparent;
- border-radius: var(--border-radius-large);
- cursor: pointer;
- transition-property: background-color;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- transition-duration: 200ms;
- flex-shrink: 0;
-}
-
-.toggle-button.active {
- background-color: var(--w-500);
-}
-
-.toggle-button span {
- --space-one-point-five: 1.5rem;
- height: var(--space-one-point-five);
- width: var(--space-one-point-five);
- display: inline-block;
- background-color: var(--white);
- box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
- rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
- rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
- transform: translate(0, 0);
- border-radius: 100%;
- transition-property: transform;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
- transition-duration: 200ms;
-}
-.toggle-button span.active {
- transform: translate(var(--space-one-point-five), var(--space-zero));
-}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue
index 6c01bc3dd..bf0c22515 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/PreChatForm/Settings.vue
@@ -15,28 +15,57 @@
-
-
-
-
-
@@ -49,6 +50,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector';
import { GROUP_BY_FILTER } from './constants';
+import reportMixin from '../../../../mixins/reportMixin';
const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
@@ -63,6 +65,7 @@ export default {
components: {
ReportFilterSelector,
},
+ mixins: [reportMixin],
data() {
return {
from: 0,
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue
index 2fd32ee69..857a74363 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/ReportFilters.vue
@@ -255,7 +255,6 @@ export default {
},
methods: {
onDateRangeChange() {
- console.log(this.from, this.to);
this.$emit('date-range-change', {
from: this.from,
to: this.to,
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue
index 449b0383f..8ce264080 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue
@@ -27,7 +27,8 @@
:heading="metric.NAME"
:index="index"
:on-click="changeSelection"
- :point="accountSummary[metric.KEY]"
+ :point="displayMetric(metric.KEY)"
+ :trend="calculateTrend(metric.KEY)"
:selected="index === currentSelection"
/>
@@ -55,6 +56,7 @@ import ReportFilters from './ReportFilters';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import { GROUP_BY_FILTER } from '../constants';
+import reportMixin from '../../../../../mixins/reportMixin';
const REPORTS_KEYS = {
CONVERSATIONS: 'conversations_count',
@@ -68,6 +70,7 @@ export default {
components: {
ReportFilters,
},
+ mixins: [reportMixin],
props: {
type: {
type: String,
diff --git a/app/javascript/dashboard/store/modules/labels.js b/app/javascript/dashboard/store/modules/labels.js
index 4183c28ed..f597212a3 100644
--- a/app/javascript/dashboard/store/modules/labels.js
+++ b/app/javascript/dashboard/store/modules/labels.js
@@ -45,7 +45,8 @@ export const actions = {
const response = await LabelsAPI.create(cannedObj);
commit(types.ADD_LABEL, response.data);
} catch (error) {
- throw new Error(error);
+ const errorMessage = error?.response?.data?.message;
+ throw new Error(errorMessage);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isCreating: false });
}
diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js
index f2967945a..cb1efe3ff 100644
--- a/app/javascript/dashboard/store/modules/reports.js
+++ b/app/javascript/dashboard/store/modules/reports.js
@@ -5,7 +5,6 @@ import * as types from '../mutation-types';
import Report from '../../api/reports';
import { downloadCsvFile } from '../../helper/downloadCsvFile';
-import { formatTime } from '@chatwoot/utils';
const state = {
fetchingStatus: false,
@@ -21,6 +20,7 @@ const state = {
incoming_messages_count: 0,
outgoing_messages_count: 0,
resolutions_count: 0,
+ previous: {},
},
};
@@ -125,18 +125,6 @@ const mutations = {
},
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
- // Average First Response Time
- let avgFirstResTimeInHr = 0;
- if (summaryData.avg_first_response_time) {
- avgFirstResTimeInHr = formatTime(summaryData.avg_first_response_time);
- }
- // Average Resolution Time
- let avgResolutionTimeInHr = 0;
- if (summaryData.avg_resolution_time) {
- avgResolutionTimeInHr = formatTime(summaryData.avg_resolution_time);
- }
- _state.accountSummary.avg_first_response_time = avgFirstResTimeInHr;
- _state.accountSummary.avg_resolution_time = avgResolutionTimeInHr;
},
};
diff --git a/app/javascript/sdk/IFrameHelper.js b/app/javascript/sdk/IFrameHelper.js
index a7f9dc70a..032519912 100644
--- a/app/javascript/sdk/IFrameHelper.js
+++ b/app/javascript/sdk/IFrameHelper.js
@@ -119,7 +119,8 @@ export const IFrameHelper = {
},
setupAudioListeners: () => {
- getAlertAudio().then(() =>
+ const { baseUrl = '' } = window.$chatwoot;
+ getAlertAudio(baseUrl).then(() =>
initOnEvents.forEach(event => {
document.removeEventListener(
event,
diff --git a/app/javascript/shared/components/FluentIcon/dashboard-icons.json b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
index 0cbc85d22..9f19f8a9d 100644
--- a/app/javascript/shared/components/FluentIcon/dashboard-icons.json
+++ b/app/javascript/shared/components/FluentIcon/dashboard-icons.json
@@ -126,5 +126,6 @@
"brand-whatsapp-outline": "M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z",
"brand-github-outline": "M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12Z",
"add-solid": "M11.883 3.007 12 3a1 1 0 0 1 .993.883L13 4v7h7a1 1 0 0 1 .993.883L21 12a1 1 0 0 1-.883.993L20 13h-7v7a1 1 0 0 1-.883.993L12 21a1 1 0 0 1-.993-.883L11 20v-7H4a1 1 0 0 1-.993-.883L3 12a1 1 0 0 1 .883-.993L4 11h7V4a1 1 0 0 1 .883-.993L12 3l-.117.007Z",
- "subtract-solid": "M3.997 13H20a1 1 0 1 0 0-2H3.997a1 1 0 1 0 0 2Z"
+ "subtract-solid": "M3.997 13H20a1 1 0 1 0 0-2H3.997a1 1 0 1 0 0 2Z",
+ "drag-outline": "M15 3.707V8.5a.5.5 0 0 0 1 0V3.707l1.146 1.147a.5.5 0 0 0 .708-.708l-2-2a.499.499 0 0 0-.708 0l-2 2a.5.5 0 0 0 .708.708L15 3.707ZM2 4.5a.5.5 0 0 1 .5-.5h6a.5.5 0 0 1 0 1h-6a.5.5 0 0 1-.5-.5Zm0 5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5Zm.5 4.5a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1h-6ZM15 16.293V11.5a.5.5 0 0 1 1 0v4.793l1.146-1.147a.5.5 0 0 1 .708.708l-2 2a.5.5 0 0 1-.708 0l-2-2a.5.5 0 0 1 .708-.708L15 16.293Z"
}
diff --git a/app/javascript/shared/helpers/AudioNotificationHelper.js b/app/javascript/shared/helpers/AudioNotificationHelper.js
index 3713d1531..672816f90 100644
--- a/app/javascript/shared/helpers/AudioNotificationHelper.js
+++ b/app/javascript/shared/helpers/AudioNotificationHelper.js
@@ -4,7 +4,7 @@ import { IFrameHelper } from 'widget/helpers/utils';
import { showBadgeOnFavicon } from './faviconHelper';
export const initOnEvents = ['click', 'touchstart', 'keypress'];
-export const getAlertAudio = async () => {
+export const getAlertAudio = async (baseUrl = '') => {
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const playsound = audioBuffer => {
window.playAudioAlert = () => {
@@ -17,7 +17,8 @@ export const getAlertAudio = async () => {
};
try {
- const audioRequest = new Request('/dashboard/audios/ding.mp3');
+ const resourceUrl = `${baseUrl}/dashboard/audios/ding.mp3`;
+ const audioRequest = new Request(resourceUrl);
fetch(audioRequest)
.then(response => response.arrayBuffer())
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index f57bbd1d5..3bc89ec2f 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -47,7 +47,12 @@ class MessageFormatter {
const markedDownOutput = marked(withHash);
return markedDownOutput;
}
- return marked(this.message, { breaks: true, gfm: true });
+ DOMPurify.addHook('afterSanitizeAttributes', node => {
+ if ('target' in node) node.setAttribute('target', '_blank');
+ });
+ return DOMPurify.sanitize(
+ marked(this.message, { breaks: true, gfm: true })
+ );
}
get formattedMessage() {
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 3ac99dbdd..4e13fe832 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -6,14 +6,14 @@ describe('#MessageFormatter', () => {
const message =
'Chatwoot is an opensource tool. [Chatwoot](https://www.chatwoot.com)';
expect(new MessageFormatter(message).formattedMessage).toMatch(
- 'Chatwoot is an opensource tool. Chatwoot '
+ 'Chatwoot is an opensource tool. Chatwoot '
);
});
it('should format correctly', () => {
const message =
'Chatwoot is an opensource tool. https://www.chatwoot.com';
expect(new MessageFormatter(message).formattedMessage).toMatch(
- 'Chatwoot is an opensource tool. https://www.chatwoot.com '
+ 'Chatwoot is an opensource tool. https://www.chatwoot.com '
);
});
});
@@ -58,4 +58,14 @@ describe('#MessageFormatter', () => {
);
});
});
+
+ describe('#sanitize', () => {
+ it('sanitizes markup and removes all unnecessary elements', () => {
+ const message =
+ '[xssLink](javascript:alert(document.cookie))\n[normalLink](https://google.com)**I am a bold text paragraph**';
+ expect(new MessageFormatter(message).formattedMessage).toMatch(
+ 'xssLink normalLinkI am a bold text paragraph '
+ );
+ });
+ });
});
diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js
index df37f2363..a9beba74f 100755
--- a/app/javascript/widget/api/endPoints.js
+++ b/app/javascript/widget/api/endPoints.js
@@ -10,6 +10,7 @@ const createConversation = params => {
contact: {
name: params.fullName,
email: params.emailAddress,
+ phone_number: params.phoneNumber,
},
message: {
content: params.message,
diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue
index 7fdf33375..b4b2eacfc 100644
--- a/app/javascript/widget/components/PreChat/Form.vue
+++ b/app/javascript/widget/components/PreChat/Form.vue
@@ -9,30 +9,40 @@
>
{{ headerMessage }}
-
-
+
+
+
+
+
+
({}),
+ default: () => {},
},
disableContactFields: {
type: Boolean,
@@ -83,15 +95,33 @@ export default {
},
},
validations() {
- const identityValidations = {
- fullName: {
- required,
- },
- emailAddress: {
- required,
- email,
- },
- };
+ let identityValidations = {};
+ if (this.isContactFieldRequired('emailAddress')) {
+ identityValidations = {
+ ...identityValidations,
+ emailAddress: {
+ required,
+ email,
+ },
+ };
+ }
+ if (this.isContactFieldRequired('phoneNumber')) {
+ identityValidations = {
+ ...identityValidations,
+ phoneNumber: {
+ required,
+ isPhoneE164OrEmpty,
+ },
+ };
+ }
+ if (this.isContactFieldRequired('fullName')) {
+ identityValidations = {
+ ...identityValidations,
+ fullName: {
+ required,
+ },
+ };
+ }
const messageValidation = {
message: {
@@ -103,18 +133,16 @@ export default {
if (this.hasActiveCampaign) {
return identityValidations;
}
- if (this.areContactFieldsVisible) {
- return {
- ...identityValidations,
- ...messageValidation,
- };
- }
- return messageValidation;
+ return {
+ ...identityValidations,
+ ...messageValidation,
+ };
},
data() {
return {
fullName: '',
emailAddress: '',
+ phoneNumber: '',
message: '',
};
},
@@ -139,11 +167,48 @@ export default {
}
return this.options.preChatMessage;
},
- areContactFieldsVisible() {
- return this.options.requireEmail && !this.disableContactFields;
+ preChatFields() {
+ return this.options.preChatFields || [];
+ },
+ emailErrorMessage() {
+ let errorMessage = '';
+ if (!this.$v.emailAddress.$error) {
+ errorMessage = '';
+ } else if (!this.$v.emailAddress.required) {
+ errorMessage = this.$t(
+ 'PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.REQUIRED_ERROR'
+ );
+ } else if (!this.$v.emailAddress.email) {
+ errorMessage = this.$t(
+ 'PRE_CHAT_FORM.FIELDS.EMAIL_ADDRESS.VALID_ERROR'
+ );
+ }
+ return errorMessage;
+ },
+ phoneNumberErrorMessage() {
+ let errorMessage = '';
+ if (!this.$v.phoneNumber.$error) {
+ errorMessage = '';
+ } else if (!this.$v.phoneNumber.required) {
+ errorMessage = this.$t(
+ 'PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.REQUIRED_ERROR'
+ );
+ } else if (!this.$v.phoneNumber.email) {
+ errorMessage = this.$t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.VALID_ERROR');
+ }
+ return errorMessage;
},
},
methods: {
+ isContactFieldVisible(field, item) {
+ return (
+ item.name === field &&
+ this.preChatFields.find(option => option.name === field).enabled
+ );
+ },
+ isContactFieldRequired(field) {
+ return this.preChatFields.find(option => option.name === field).required;
+ },
onSubmit() {
this.$v.$touch();
if (this.$v.$invalid) {
@@ -151,6 +216,7 @@ export default {
}
this.$emit('submit', {
fullName: this.fullName,
+ phoneNumber: this.phoneNumber,
emailAddress: this.emailAddress,
message: this.message,
activeCampaignId: this.activeCampaign.id,
diff --git a/app/javascript/widget/i18n/locale/en.json b/app/javascript/widget/i18n/locale/en.json
index 7718ea4d1..94f4dd445 100644
--- a/app/javascript/widget/i18n/locale/en.json
+++ b/app/javascript/widget/i18n/locale/en.json
@@ -43,12 +43,19 @@
"FULL_NAME": {
"LABEL": "Full Name",
"PLACEHOLDER": "Please enter your full name",
- "ERROR": "Full Name is required"
+ "REQUIRED_ERROR": "Full Name is required"
},
"EMAIL_ADDRESS": {
"LABEL": "Email Address",
"PLACEHOLDER": "Please enter your email address",
- "ERROR": "Invalid email address"
+ "REQUIRED_ERROR": "Email Address is required",
+ "VALID_ERROR": "Please enter a valid email address"
+ },
+ "PHONE_NUMBER": {
+ "LABEL": "Phone Number",
+ "PLACEHOLDER": "Please enter your phone number",
+ "REQUIRED_ERROR": "Phone Number is required",
+ "VALID_ERROR": "Phone number should be of E.164 format eg: +1415555555"
},
"MESSAGE": {
"LABEL": "Message",
diff --git a/app/javascript/widget/mixins/configMixin.js b/app/javascript/widget/mixins/configMixin.js
index c91730767..1f1aec538 100644
--- a/app/javascript/widget/mixins/configMixin.js
+++ b/app/javascript/widget/mixins/configMixin.js
@@ -24,12 +24,15 @@ export default {
preChatFormOptions() {
let requireEmail = false;
let preChatMessage = '';
+ let preChatFields = null;
const options = window.chatwootWebChannel.preChatFormOptions || {};
requireEmail = options.require_email;
preChatMessage = options.pre_chat_message;
+ preChatFields = options.pre_chat_fields;
return {
requireEmail,
preChatMessage,
+ preChatFields,
};
},
},
diff --git a/app/javascript/widget/views/PreChatForm.vue b/app/javascript/widget/views/PreChatForm.vue
index cb0b4c217..24700cfcc 100644
--- a/app/javascript/widget/views/PreChatForm.vue
+++ b/app/javascript/widget/views/PreChatForm.vue
@@ -35,13 +35,20 @@ export default {
},
},
methods: {
- onSubmit({ fullName, emailAddress, message, activeCampaignId }) {
+ onSubmit({
+ fullName,
+ emailAddress,
+ message,
+ activeCampaignId,
+ phoneNumber,
+ }) {
if (activeCampaignId) {
bus.$emit('execute-campaign', activeCampaignId);
this.$store.dispatch('contacts/update', {
user: {
email: emailAddress,
name: fullName,
+ phone_number: phoneNumber,
},
});
} else {
@@ -49,6 +56,7 @@ export default {
fullName: fullName,
emailAddress: emailAddress,
message: message,
+ phoneNumber: phoneNumber,
});
}
},
diff --git a/app/models/attachment.rb b/app/models/attachment.rb
index 6def5bbdb..6193fbb91 100644
--- a/app/models/attachment.rb
+++ b/app/models/attachment.rb
@@ -34,7 +34,7 @@ class Attachment < ApplicationRecord
has_one_attached :file
validate :acceptable_file
- enum file_type: [:image, :audio, :video, :file, :location, :fallback]
+ enum file_type: [:image, :audio, :video, :file, :location, :fallback, :share, :story_mention]
def push_event_data
return unless file_type
diff --git a/app/models/channel/web_widget.rb b/app/models/channel/web_widget.rb
index 7c3edcc77..59a978c6e 100644
--- a/app/models/channel/web_widget.rb
+++ b/app/models/channel/web_widget.rb
@@ -32,7 +32,8 @@ class Channel::WebWidget < ApplicationRecord
self.table_name = 'channel_web_widgets'
EDITABLE_ATTRS = [:website_url, :widget_color, :welcome_title, :welcome_tagline, :reply_time, :pre_chat_form_enabled,
:continuity_via_email, :hmac_mandatory,
- { pre_chat_form_options: [:pre_chat_message, :require_email] },
+ { pre_chat_form_options: [:pre_chat_message, :require_email,
+ { pre_chat_fields: [:label, :name, :enabled, :type, :enabled, :required] }] },
{ selected_feature_flags: [] }].freeze
validates :website_url, presence: true
diff --git a/app/models/message.rb b/app/models/message.rb
index c9113dcf0..6ad607997 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -64,7 +64,7 @@ class Message < ApplicationRecord
# [:deleted] : Used to denote whether the message was deleted by the agent
# [:external_created_at] : Can specify if the message was created at a different timestamp externally
store :content_attributes, accessors: [:submitted_email, :items, :submitted_values, :email, :in_reply_to, :deleted,
- :external_created_at], coder: JSON
+ :external_created_at, :story_sender, :story_id], coder: JSON
store :external_source_ids, accessors: [:slack], coder: JSON, prefix: :external_source_id
diff --git a/config/environments/development.rb b/config/environments/development.rb
index eab9c411d..67ffa6a52 100644
--- a/config/environments/development.rb
+++ b/config/environments/development.rb
@@ -82,6 +82,7 @@ Rails.application.configure do
allow do
origins '*'
resource '/packs/*', headers: :any, methods: [:get, :options]
+ resource '/dashboard/audios/ding.mp3', headers: :any, methods: [:get, :options]
resource '*', headers: :any, methods: :any, expose: ['access-token', 'client', 'uid', 'expiry']
end
end
diff --git a/config/environments/production.rb b/config/environments/production.rb
index fb6c124d8..e538e2867 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -121,6 +121,7 @@ Rails.application.configure do
allow do
origins '*'
resource '/packs/*', headers: :any, methods: [:get, :options]
+ resource '/dashboard/audios/ding.mp3', headers: :any, methods: [:get, :options]
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('CW_API_ONLY_SERVER', false))
resource '*', headers: :any, methods: :any, expose: ['access-token', 'client', 'uid', 'expiry']
end
diff --git a/config/environments/staging.rb b/config/environments/staging.rb
index 36efcc171..6e9b3dcf8 100644
--- a/config/environments/staging.rb
+++ b/config/environments/staging.rb
@@ -87,6 +87,7 @@ Rails.application.configure do
allow do
origins '*'
resource '/packs/*', headers: :any, methods: [:get, :options]
+ resource '/dashboard/audios/ding.mp3', headers: :any, methods: [:get, :options]
end
end
end
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 6a2534654..24baf5aff 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -58,6 +58,7 @@ Rails.application.configure do
allow do
origins '*'
resource '/packs/*', headers: :any, methods: [:get, :options]
+ resource '/dashboard/audios/ding.mp3', headers: :any, methods: [:get, :options]
end
end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c52b660e8..8c18482af 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -43,7 +43,7 @@ en:
failed: Signup failed
contacts:
import:
- failed: File is blank
+ failed: File is blank
reports:
period: Reporting period %{since} to %{until}
@@ -67,6 +67,7 @@ en:
conversation_mention: "You have been mentioned in conversation [ID - %{display_id}] by %{name}"
conversations:
messages:
+ instagram_story_content: "%{story_sender} mentioned you in the story: "
deleted: This message was deleted
activity:
status:
diff --git a/db/migrate/20220309103742_add_custom_fields_to_pre_chat_form.rb b/db/migrate/20220309103742_add_custom_fields_to_pre_chat_form.rb
new file mode 100644
index 000000000..c04e11ba7
--- /dev/null
+++ b/db/migrate/20220309103742_add_custom_fields_to_pre_chat_form.rb
@@ -0,0 +1,28 @@
+class AddCustomFieldsToPreChatForm < ActiveRecord::Migration[6.1]
+ def change
+ Channel::WebWidget.find_in_batches do |channels_batch|
+ channels_batch.each do |channel|
+ pre_chat_message = channel[:pre_chat_form_options]['pre_chat_message'] || 'Share your queries or comments here.'
+ pre_chat_fields = pre_chat_fields?(channel)
+ channel[:pre_chat_form_options] = {
+ 'pre_chat_message': pre_chat_message,
+ 'pre_chat_fields': pre_chat_fields
+ }
+ channel.save!
+ end
+ end
+ end
+
+ def pre_chat_fields?(channel)
+ email_enabled = channel[:pre_chat_form_options]['require_email'] || false
+ [
+ {
+ 'label': 'Email Id', 'name': 'emailAddress', 'type': 'email', 'required': true, 'enabled': email_enabled
+ }, {
+ 'label': 'Full name', 'name': 'fullName', 'type': 'text', 'required': true, 'enabled': email_enabled
+ }, {
+ 'label': 'Phone number', 'name': 'phoneNumber', 'type': 'number', 'required': true, 'enabled': false
+ }
+ ]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 740f2ed70..1a7924bc1 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2022_03_08_193420) do
+ActiveRecord::Schema.define(version: 2022_03_09_103742) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
diff --git a/spec/factories/instagram/instagram_message_create_event.rb b/spec/factories/instagram/instagram_message_create_event.rb
index d0dbffdab..b4ffa2dff 100644
--- a/spec/factories/instagram/instagram_message_create_event.rb
+++ b/spec/factories/instagram/instagram_message_create_event.rb
@@ -55,4 +55,72 @@ FactoryBot.define do
end
initialize_with { attributes }
end
+
+ factory :instagram_message_attachment_event, class: Hash do
+ entry do
+ [
+ {
+ 'id': 'instagram-message-id-1234',
+ 'time': '2021-09-08T06:34:04+0000',
+ 'messaging': [
+ {
+ 'sender': {
+ 'id': 'Sender-id-1'
+ },
+ 'recipient': {
+ 'id': 'chatwoot-app-user-id-1'
+ },
+ 'timestamp': '2021-09-08T06:34:04+0000',
+ 'message': {
+ 'mid': 'message-id-1',
+ 'attachments': [
+ {
+ 'type': 'share',
+ 'payload': {
+ 'url': 'https://imagekit.io/blog/content/images/2020/05/media_library.jpeg'
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ end
+ initialize_with { attributes }
+ end
+
+ factory :instagram_story_mention_event, class: Hash do
+ entry do
+ [
+ {
+ 'id': 'instagram-message-id-1234',
+ 'time': '2021-09-08T06:34:04+0000',
+ 'messaging': [
+ {
+ 'sender': {
+ 'id': 'Sender-id-1'
+ },
+ 'recipient': {
+ 'id': 'chatwoot-app-user-id-1'
+ },
+ 'timestamp': '2021-09-08T06:34:04+0000',
+ 'message': {
+ 'mid': 'message-id-1',
+ 'attachments': [
+ {
+ 'type': 'story_mention',
+ 'payload': {
+ 'url': 'https://imagekit.io/blog/content/images/2020/05/media_library.jpeg'
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ end
+ initialize_with { attributes }
+ end
end
diff --git a/spec/jobs/webhooks/instagram_events_job_spec.rb b/spec/jobs/webhooks/instagram_events_job_spec.rb
index cbda62db9..eaf77bf7b 100644
--- a/spec/jobs/webhooks/instagram_events_job_spec.rb
+++ b/spec/jobs/webhooks/instagram_events_job_spec.rb
@@ -6,13 +6,30 @@ describe Webhooks::InstagramEventsJob do
before do
stub_request(:post, /graph.facebook.com/)
+ stub_request(:get, 'https://imagekit.io/blog/content/images/2020/05/media_library.jpeg')
+ .with(
+ headers: {
+ 'Accept' => '*/*',
+ 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
+ 'User-Agent' => 'Down/5.3.0'
+ }
+ )
+ .to_return(status: 200, body: '', headers: {})
end
let!(:account) { create(:account) }
+ let(:return_onject) do
+ { name: 'Jane',
+ id: 'Sender-id-1',
+ account_id: instagram_inbox.account_id,
+ profile_pic: 'https://chatwoot-assets.local/sample.png' }
+ end
let!(:instagram_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
let!(:test_params) { build(:instagram_test_text_event).with_indifferent_access }
+ let!(:attachment_params) { build(:instagram_message_attachment_event).with_indifferent_access }
+ let!(:story_mention_params) { build(:instagram_story_mention_event).with_indifferent_access }
let(:fb_object) { double }
describe '#perform' do
@@ -20,12 +37,7 @@ describe Webhooks::InstagramEventsJob do
it 'creates incoming message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
- {
- name: 'Jane',
- id: 'Sender-id-1',
- account_id: instagram_inbox.account_id,
- profile_pic: 'https://chatwoot-assets.local/sample.png'
- }.with_indifferent_access
+ return_onject.with_indifferent_access
)
instagram_webhook.perform_now(dm_params[:entry])
@@ -39,12 +51,7 @@ describe Webhooks::InstagramEventsJob do
it 'creates test text message in the instagram inbox' do
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
- {
- name: 'Jane',
- id: 'Sender-id-1',
- account_id: instagram_inbox.account_id,
- profile_pic: 'https://chatwoot-assets.local/sample.png'
- }.with_indifferent_access
+ return_onject.with_indifferent_access
)
instagram_webhook.perform_now(test_params[:entry])
@@ -53,6 +60,46 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1
expect(instagram_inbox.messages.last.content).to eq('This is a test message from facebook.')
end
+
+ it 'creates incoming message with attachments in the instagram inbox' do
+ allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ allow(fb_object).to receive(:get_object).and_return(
+ return_onject.with_indifferent_access
+ )
+ instagram_webhook.perform_now(attachment_params[:entry])
+
+ instagram_inbox.reload
+
+ expect(instagram_inbox.contacts.count).to be 1
+ expect(instagram_inbox.messages.count).to be 1
+ expect(instagram_inbox.messages.last.attachments.count).to be 1
+ end
+
+ it 'creates incoming message with attachments in the instagram inbox for story mention' do
+ allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
+ allow(fb_object).to receive(:get_object).and_return(
+ return_onject.with_indifferent_access,
+ { story:
+ {
+ mention: {
+ link:
+ 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17920786367196703&signature=Aby8EXbvNu4on9efDQecXDasiJX2s0FgWhFGz3mNFB__CsHR22O_1bJiYHkbp3mC1NQeW4jHxls9WyqVgRPcyonUbSJmD44UwLfFhbCK2obesWnFi7VOnisqLu48Xd6KYuNex7uSCQKWM-nw55zQ23bBgfCYw6h5hiJjFHwJDZYm65zXpQ',
+ id: '17920786367196703'
+ }
+ },
+ from: {
+ username: 'Sender-id-1', id: 'Sender-id-1'
+ },
+ id: 'instagram-message-id-1234' }.with_indifferent_access
+ )
+
+ instagram_webhook.perform_now(story_mention_params[:entry])
+
+ instagram_inbox.reload
+
+ expect(instagram_inbox.messages.count).to be 1
+ expect(instagram_inbox.messages.last.attachments.count).to be 1
+ end
end
end
end
diff --git a/swagger/definitions/resource/report.yml b/swagger/definitions/resource/report.yml
index 9a4bffe9c..8dcbf3f0e 100644
--- a/swagger/definitions/resource/report.yml
+++ b/swagger/definitions/resource/report.yml
@@ -1,6 +1,29 @@
type: object
properties:
- value:
- type: number
- timestamp:
+ avg_first_response_time:
type: string
+ avg_resolution_time:
+ type: string
+ conversations_count:
+ type: number
+ incoming_messages_count:
+ type: number
+ outgoing_messages_count:
+ type: number
+ resolutions_count:
+ type: number
+ previous:
+ type: object
+ properties:
+ avg_first_response_time:
+ type: string
+ avg_resolution_time:
+ type: string
+ conversations_count:
+ type: number
+ incoming_messages_count:
+ type: number
+ outgoing_messages_count:
+ type: number
+ resolutions_count:
+ type: number
\ No newline at end of file
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 39cc9f95a..8e574fedc 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -4692,11 +4692,46 @@
{
"type": "object",
"properties": {
- "value": {
+ "avg_first_response_time": {
+ "type": "string"
+ },
+ "avg_resolution_time": {
+ "type": "string"
+ },
+ "conversations_count": {
"type": "number"
},
- "timestamp": {
- "type": "string"
+ "incoming_messages_count": {
+ "type": "number"
+ },
+ "outgoing_messages_count": {
+ "type": "number"
+ },
+ "resolutions_count": {
+ "type": "number"
+ },
+ "previous": {
+ "type": "object",
+ "properties": {
+ "avg_first_response_time": {
+ "type": "string"
+ },
+ "avg_resolution_time": {
+ "type": "string"
+ },
+ "conversations_count": {
+ "type": "number"
+ },
+ "incoming_messages_count": {
+ "type": "number"
+ },
+ "outgoing_messages_count": {
+ "type": "number"
+ },
+ "resolutions_count": {
+ "type": "number"
+ }
+ }
}
}
}
|