Compare commits

..
Author SHA1 Message Date
Sivin VargheseandGitHub d60fe03db3 Merge branch 'develop' into feat/CW-3082 2024-05-23 11:35:42 +05:30
iamsivin 4fdbaa200c chore: Color fix 2024-04-30 21:50:35 +05:30
Sivin VargheseandGitHub bbe4508454 Merge branch 'develop' into feat/CW-3082 2024-04-30 21:44:09 +05:30
Sivin VargheseandGitHub 4f20741c95 Merge branch 'develop' into feat/CW-3082 2024-04-29 17:33:17 +05:30
Sivin VargheseandGitHub 7c7aebc0fd Merge branch 'develop' into feat/CW-3082 2024-04-24 09:27:10 +05:30
Sivin VargheseandGitHub 801d9b51da Merge branch 'develop' into feat/CW-3082 2024-04-15 09:54:44 +05:30
Sivin VargheseandGitHub 2086f52edd Merge branch 'develop' into feat/CW-3082 2024-04-12 13:44:30 +05:30
Shivam MishraandGitHub a1541a85ca Merge branch 'develop' into feat/CW-3082 2024-04-10 12:18:58 +05:30
Sivin VargheseandGitHub 0c868fecaa Merge branch 'develop' into feat/CW-3082 2024-04-08 21:42:41 +05:30
Sivin VargheseandGitHub 59fa7926b1 Merge branch 'develop' into feat/CW-3082 2024-04-04 22:53:27 +05:30
Sivin VargheseandGitHub d8108ee324 Merge branch 'develop' into feat/CW-3082 2024-04-01 12:07:35 +05:30
Sivin VargheseandGitHub f37fd877bb Merge branch 'develop' into feat/CW-3082 2024-03-27 13:21:29 +05:30
Sivin VargheseandGitHub 64ff91afbb Merge branch 'develop' into feat/CW-3082 2024-03-25 23:41:38 +05:30
Sivin VargheseandGitHub 271b7da094 Merge branch 'develop' into feat/CW-3082 2024-03-21 15:08:36 +05:30
Sivin VargheseandGitHub 1588db0d83 Merge branch 'develop' into feat/CW-3082 2024-03-18 19:45:05 +05:30
Sivin VargheseandGitHub f4f068a366 Merge branch 'develop' into feat/CW-3082 2024-03-18 17:14:14 +05:30
iamsivin 279c11f38f fix: snapshot 2024-03-18 11:25:47 +05:30
Sivin VargheseandGitHub d9dee0c9da Merge branch 'develop' into feat/CW-3082 2024-03-17 20:06:10 +05:30
iamsivin 82dda9ba22 chore: Review fix 2024-03-15 19:38:24 +05:30
Shivam Mishra 0b1238814c test: resize directive 2024-03-15 16:47:49 +05:30
Shivam Mishra 9563281b16 refactor: use a custom directive for resize 2024-03-15 16:35:01 +05:30
Shivam Mishra 8925cf1e94 chore: run tailwind format 2024-03-15 16:30:33 +05:30
Shivam Mishra af90e3a56c feat: add resize directive 2024-03-15 16:30:23 +05:30
iamsivin b581d19c93 feat: Add sla types 2024-03-15 13:35:23 +05:30
iamsivin ccca226f82 chore: Review fix 2024-03-15 12:43:00 +05:30
Sivin VargheseandGitHub dc392da069 Merge branch 'develop' into feat/CW-3082 2024-03-15 12:41:41 +05:30
Muhsin KelothandGitHub a87ebb1f98 Merge branch 'develop' into feat/CW-3082 2024-03-15 11:59:12 +05:30
Sivin VargheseandGitHub 884433ed1f Merge branch 'develop' into feat/CW-3082 2024-03-15 11:27:41 +05:30
Muhsin Keloth dd2617cf37 chore: add sla title 2024-03-15 11:02:49 +05:30
iamsivin 9848b43b57 chore: clean up 2024-03-15 09:19:45 +05:30
iamsivin eb919456f2 chore: style fix 2024-03-15 09:13:39 +05:30
Sivin VargheseandGitHub ab2e2b92e2 Merge branch 'develop' into feat/CW-3082 2024-03-14 17:30:25 +05:30
iamsivin 905cb652d5 chore: clean up 2024-03-14 17:28:42 +05:30
iamsivin 162efff711 chore: Ui fixes 2024-03-14 17:05:27 +05:30
iamsivin 3a021569d4 fix: responsive issues 2024-03-14 16:40:49 +05:30
iamsivin 8b207a9a94 fix: notification types 2024-03-14 15:53:34 +05:30
iamsivin f419dcc662 chore: Adds specs 2024-03-14 15:18:24 +05:30
iamsivin 048e1d9bc4 chore: Use resize observer 2024-03-14 15:13:40 +05:30
Shivam Mishra 3cc1147fa8 refactor: use simple estimation based logic to calculate number of labels 2024-03-14 13:46:02 +05:30
Sivin VargheseandGitHub 51e293976f Merge branch 'develop' into feat/CW-3082 2024-03-14 09:34:12 +05:30
iamsivin 28563ca129 chore: Minor fix 2024-03-13 23:38:33 +05:30
iamsivin 4d433de344 feat: Update inbox card design 2024-03-12 11:47:08 +05:30
165 changed files with 940 additions and 3271 deletions
+6 -5
View File
@@ -1,9 +1,4 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
@@ -189,6 +184,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# SENTRY_DSN=
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
+1 -2
View File
@@ -75,5 +75,4 @@ yalc.lock
yarn-debug.log*
.yarn-integrity
/storybook-static
config/chatwoot-fcm-service-account-file.json
/storybook-static
+1 -1
View File
@@ -555,7 +555,7 @@ GEM
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.0)
rack (>= 2.0.0)
@@ -88,6 +88,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
params.permit(:team_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end
-5
View File
@@ -3,7 +3,6 @@ class DashboardController < ActionController::Base
before_action :set_application_pack
before_action :set_global_config
before_action :set_dashboard_scripts
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
@@ -36,10 +35,6 @@ class DashboardController < ActionController::Base
).merge(app_config)
end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end
@@ -7,14 +7,9 @@ class Microsoft::CallbacksController < ApplicationController
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox, already_exists = find_or_create_inbox
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
if already_exists
redirect_to app_microsoft_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -45,17 +40,9 @@ class Microsoft::CallbacksController < ApplicationController
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
channel_email.inbox
end
# Fallback name, for when name field is missing from users_data
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
%w[ENABLE_ACCOUNT_SIGNUP]
end
end
end
-9
View File
@@ -27,7 +27,6 @@
<script>
import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
@@ -44,7 +43,6 @@ import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
export default {
name: 'App',
@@ -66,7 +64,6 @@ export default {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
};
},
@@ -105,11 +102,6 @@ export default {
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
},
beforeDestroy() {
if (this.reconnectService) {
this.reconnectService.disconnect();
}
},
methods: {
initializeColorTheme() {
setColorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -133,7 +125,6 @@ export default {
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
-7
View File
@@ -9,13 +9,6 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
@@ -15,7 +15,6 @@ class ConversationApi extends ApiClient {
teamId,
conversationType,
sortBy,
updatedWithin,
}) {
return axios.get(this.url, {
params: {
@@ -27,7 +26,6 @@ class ConversationApi extends ApiClient {
labels,
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
},
});
}
@@ -1,47 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId, title) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
title: title,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();
@@ -46,7 +46,6 @@ describe('#ConversationAPI', () => {
page: 1,
labels: [],
teamId: 1,
updatedWithin: 20,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
params: {
@@ -56,7 +55,6 @@ describe('#ConversationAPI', () => {
assignee_type: 'me',
page: 1,
labels: [],
updated_within: 20,
},
});
});
@@ -1,202 +0,0 @@
import LinearAPIClient from '../../integrations/linear';
import ApiClient from '../../ApiClient';
describe('#linearAPI', () => {
it('creates correct instance', () => {
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
expect(LinearAPIClient).toHaveProperty('getTeams');
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
expect(LinearAPIClient).toHaveProperty('createIssue');
expect(LinearAPIClient).toHaveProperty('link_issue');
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
expect(LinearAPIClient).toHaveProperty('searchIssues');
});
describe('getTeams', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeams();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/teams'
);
});
});
describe('getTeamEntities', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeamEntities(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/team_entities?team_id=1'
);
});
});
describe('createIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.link_issue(1, 2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 2,
conversation_id: 1,
}
);
});
});
describe('getLinkedIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getLinkedIssue(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
);
});
});
describe('unlinkIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
}
);
});
});
describe('searchIssues', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.searchIssues('query');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/search_issue?q=query'
);
});
});
});
@@ -268,7 +268,6 @@ export default {
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
chatListFilters: 'getChatListFilters',
unAssignedChatsList: 'getUnAssignedChats',
chatListLoading: 'getChatListLoadingStatus',
currentUserID: 'getCurrentUserID',
@@ -294,6 +293,13 @@ export default {
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
},
savedFoldersValue() {
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
return {};
},
showEndOfListMessage() {
return (
this.conversationList.length &&
@@ -369,6 +375,7 @@ export default {
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
conversationListPagination() {
@@ -481,13 +488,7 @@ export default {
this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
},
activeFolder(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch(
'customViews/setActiveConversationFolder',
newVal || null
);
}
activeFolder() {
this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
},
@@ -497,14 +498,8 @@ export default {
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
conversationFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('updateChatListFilters', newVal);
}
},
},
mounted() {
this.$store.dispatch('setChatListFilters', this.conversationFilters);
this.setFiltersFromUISettings();
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
@@ -514,14 +509,14 @@ export default {
this.$store.dispatch('campaigns/get');
}
this.$emitter.on('fetch_conversation_stats', () => {
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
methods: {
updateVirtualListProps(key, value) {
@@ -700,9 +695,8 @@ export default {
this.fetchConversations();
},
fetchConversations() {
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
this.$store
.dispatch('fetchAllConversations')
.dispatch('fetchAllConversations', this.conversationFilters)
.then(this.emitConversationLoaded);
},
loadMoreConversations() {
@@ -742,7 +736,7 @@ export default {
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
this.$emitter.emit('clearSearchInput');
bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
this.fetchConversations();
+1 -3
View File
@@ -25,10 +25,8 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
script: {
type: String,
@@ -61,7 +59,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.script);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
@@ -27,7 +27,7 @@
/>
</span>
<woot-button
v-if="showActions && value"
v-if="showCopyAndDeleteButton"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
@@ -90,7 +90,7 @@
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showActions && value"
v-if="showCopyAndDeleteButton"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
@@ -100,7 +100,7 @@
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-if="showEditButton"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
@@ -174,6 +174,12 @@ export default {
};
},
computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
@@ -270,10 +276,10 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
@@ -18,10 +18,8 @@
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
value: {
type: String,
@@ -37,7 +35,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
toggleMasked() {
this.masked = !this.masked;
@@ -99,9 +99,7 @@ export default {
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
if (this.closeOnBackdropClick) {
this.onClose();
}
this.onClose();
}
},
},
@@ -1,133 +1,26 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useEmitter } from 'dashboard/composables/emitter';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import { useEventListener } from '@vueuse/core';
const { t } = useI18n();
const route = useRoute();
const RECONNECTED_BANNER_TIMEOUT = 2000;
const showNotification = ref(!navigator.onLine);
const isDisconnected = ref(false);
const isReconnecting = ref(false);
const isReconnected = ref(false);
let reconnectTimeout = null;
const bannerText = computed(() => {
if (isReconnecting.value) return t('NETWORK.NOTIFICATION.RECONNECTING');
if (isReconnected.value) return t('NETWORK.NOTIFICATION.RECONNECT_SUCCESS');
return t('NETWORK.NOTIFICATION.OFFLINE');
});
const iconName = computed(() => (isReconnected.value ? 'wifi' : 'wifi-off'));
const canRefresh = computed(
() => !isReconnecting.value && !isReconnected.value
);
const refreshPage = () => {
window.location.reload();
};
const closeNotification = () => {
showNotification.value = false;
isReconnected.value = false;
clearTimeout(reconnectTimeout);
};
const isInAnyOfTheRoutes = routeName => {
return (
isAConversationRoute(routeName, true) ||
isAInboxViewRoute(routeName, true) ||
isNotificationRoute(routeName, true)
);
};
const updateWebsocketStatus = () => {
isDisconnected.value = true;
showNotification.value = true;
};
const handleReconnectionCompleted = () => {
isDisconnected.value = false;
isReconnecting.value = false;
isReconnected.value = true;
showNotification.value = true;
reconnectTimeout = setTimeout(closeNotification, RECONNECTED_BANNER_TIMEOUT);
};
const handleReconnecting = () => {
if (isInAnyOfTheRoutes(route.name)) {
isReconnecting.value = true;
isReconnected.value = false;
showNotification.value = true;
} else {
handleReconnectionCompleted();
}
};
const updateOnlineStatus = event => {
// Case: Websocket is not disconnected
// If the app goes offline, show the notification
// If the app goes online, close the notification
// Case: Websocket is disconnected
// If the app goes offline, show the notification
// If the app goes online but the websocket is disconnected, don't close the notification
// If the app goes online and the websocket is not disconnected, close the notification
if (event.type === 'offline') {
showNotification.value = true;
} else if (event.type === 'online' && !isDisconnected.value) {
handleReconnectionCompleted();
}
};
useEventListener('online', updateOnlineStatus);
useEventListener('offline', updateOnlineStatus);
useEmitter(BUS_EVENTS.WEBSOCKET_DISCONNECT, updateWebsocketStatus);
useEmitter(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED,
handleReconnectionCompleted
);
useEmitter(BUS_EVENTS.WEBSOCKET_RECONNECT, handleReconnecting);
onBeforeUnmount(() => {
clearTimeout(reconnectTimeout);
});
</script>
<template>
<transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="fixed z-50 top-4 left-2 group">
<div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
<div
class="relative flex items-center justify-between w-full px-2 py-1 bg-yellow-200 rounded-lg shadow-lg dark:bg-yellow-700"
class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
>
<fluent-icon
:icon="iconName"
icon="wifi-off"
class="text-yellow-700/50 dark:text-yellow-50"
size="18"
/>
<span
class="px-2 text-xs font-medium tracking-wide text-yellow-700/70 dark:text-yellow-50"
class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
>
{{ bannerText }}
{{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
</span>
<woot-button
v-if="canRefresh"
:title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear"
size="small"
color-scheme="warning"
icon="arrow-clockwise"
class="visible transition-all duration-500 ease-in-out ml-1"
@click="refreshPage"
/>
<woot-button
@@ -141,3 +34,55 @@ onBeforeUnmount(() => {
</div>
</transition>
</template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
mixins: [globalConfigMixin],
data() {
return {
showNotification: !navigator.onLine,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
setTimeout(() => {
this.updateOnlineStatus({ type: 'offline' });
}, 4000);
});
},
beforeDestroy() {
window.removeEventListener('offline', this.updateOnlineStatus);
},
methods: {
refreshPage() {
window.location.reload();
},
closeNotification() {
this.showNotification = false;
},
updateOnlineStatus(event) {
if (event.type === 'offline') {
this.showNotification = true;
}
},
},
};
</script>
@@ -21,7 +21,7 @@ export default {
},
methods: {
onMenuItemClick() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU);
},
},
};
@@ -15,13 +15,11 @@
<script>
import WootSnackbar from './Snackbar.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
WootSnackbar,
},
mixins: [alertMixin],
props: {
duration: {
type: Number,
@@ -36,10 +34,10 @@ export default {
},
mounted() {
this.$emitter.on('newToastMessage', this.onNewToastMessage);
bus.$on('newToastMessage', this.onNewToastMessage);
},
beforeDestroy() {
this.$emitter.off('newToastMessage', this.onNewToastMessage);
bus.$off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage(message, action) {
@@ -1,5 +1,5 @@
<template>
<div class="relative flex items-center justify-end resolve-actions">
<div class="resolve-actions relative flex items-center justify-end">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -128,12 +128,12 @@ export default {
},
},
mounted() {
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
@@ -65,11 +65,11 @@
</div>
<span
v-if="warningIcon"
class="inline-flex mr-1 bg-red-50 dark:bg-red-900 p-0.5 rounded-sm"
class="inline-flex rounded-sm mr-1 bg-slate-100"
>
<fluent-icon
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="text-xxs text-red-500 dark:text-red-300"
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
class="text-xxs"
:icon="warningIcon"
size="12"
/>
@@ -22,7 +22,7 @@ import {
setYear,
isAfter,
} from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue';
@@ -185,7 +185,7 @@ const updateManualInput = (newDate, calendarType) => {
};
const handleManualInputError = message => {
useAlert(message);
bus.$emit('newToastMessage', message);
};
const resetDatePicker = () => {
@@ -201,7 +201,7 @@ const resetDatePicker = () => {
const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
useAlert('Please select a valid time range');
bus.$emit('newToastMessage', 'Please select a valid time range');
} else {
showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
@@ -1,11 +1,9 @@
<script setup>
import { ref, computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { picoSearch } from '@scmmishra/pico-search';
import ListItemButton from './DropdownListItemButton.vue';
import DropdownSearch from './DropdownSearch.vue';
import DropdownEmptyState from './DropdownEmptyState.vue';
import DropdownLoadingState from './DropdownLoadingState.vue';
const props = defineProps({
listItems: {
@@ -21,31 +19,20 @@ const props = defineProps({
default: '',
},
activeFilterId: {
type: [String, Number],
type: Number,
default: null,
},
showClearFilter: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
loadingPlaceholder: {
type: String,
default: '',
},
});
const emits = defineEmits(['on-search']);
const searchTerm = ref('');
const onSearch = debounce(value => {
const onSearch = value => {
searchTerm.value = value;
emits('on-search', value);
}, 300);
};
const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems;
@@ -60,16 +47,6 @@ const isFilterActive = id => {
if (!props.activeFilterId) return false;
return id === props.activeFilterId;
};
const shouldShowLoadingState = computed(() => {
return (
props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder
);
});
const shouldShowEmptyState = computed(() => {
return !props.isLoading && isDropdownListEmpty.value;
});
</script>
<template>
<div
@@ -78,7 +55,7 @@ const shouldShowEmptyState = computed(() => {
>
<slot name="search">
<dropdown-search
v-if="enableSearch"
v-if="enableSearch && listItems.length"
:input-value="searchTerm"
:input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter"
@@ -87,12 +64,8 @@ const shouldShowEmptyState = computed(() => {
/>
</slot>
<slot name="listItem">
<dropdown-loading-state
v-if="shouldShowLoadingState"
:message="loadingPlaceholder"
/>
<dropdown-empty-state
v-else-if="shouldShowEmptyState"
v-if="isDropdownListEmpty"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/>
<list-item-button
@@ -1,15 +0,0 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>
@@ -81,7 +81,7 @@ export default {
},
mounted() {
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
bus.$on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage;
},
@@ -352,16 +352,10 @@ export default {
// Components using this
// 1. SearchPopover.vue
this.$emitter.on(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
beforeDestroy() {
this.$emitter.off(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
},
methods: {
reloadState(content = this.value) {
@@ -71,10 +71,6 @@
:class="{ 'justify-end': isContactPanelOpen }"
>
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<more-actions :conversation-id="currentChat.id" />
</div>
</div>
@@ -93,8 +89,6 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
export default {
components: {
@@ -103,7 +97,6 @@ export default {
MoreActions,
Thumbnail,
SLACardLabel,
Linear,
},
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: {
@@ -128,9 +121,6 @@ export default {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}),
chatMetadata() {
return this.chat.meta;
@@ -188,17 +178,6 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
},
methods: {
@@ -473,11 +473,11 @@ export default {
},
mounted() {
this.hasMediaLoadError = false;
this.$emitter.on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
beforeDestroy() {
this.$emitter.off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
bus.$off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout);
},
methods: {
@@ -531,7 +531,7 @@ export default {
const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
@@ -324,12 +324,12 @@ export default {
},
created() {
this.$emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions
this.$emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat
this.$emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true;
});
},
@@ -396,7 +396,7 @@ export default {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
},
removeBusListeners() {
this.$emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
},
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
@@ -514,7 +514,7 @@ export default {
} else {
this.hasUserScrolled = true;
}
this.$emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
@@ -61,14 +61,14 @@ export default {
...mapGetters({ currentChat: 'getSelectedChat' }),
},
mounted() {
this.$emitter.on(CMD_MUTE_CONVERSATION, this.mute);
this.$emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
this.$emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
bus.$on(CMD_MUTE_CONVERSATION, this.mute);
bus.$on(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
destroyed() {
this.$emitter.off(CMD_MUTE_CONVERSATION, this.mute);
this.$emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
this.$emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
bus.$off(CMD_MUTE_CONVERSATION, this.mute);
bus.$off(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
},
methods: {
mute() {
@@ -596,15 +596,12 @@ export default {
);
this.fetchAndSetReplyTo();
this.$emitter.on(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
// A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop
// TODO need to find a better solution
this.$emitter.on(
bus.$on(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
@@ -612,13 +609,10 @@ export default {
destroyed() {
document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents);
this.$emitter.off(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
bus.$off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo);
},
beforeDestroy() {
this.$emitter.off(
bus.$off(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive
);
@@ -631,7 +625,7 @@ export default {
const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' ');
this.$emitter.emit(
bus.$emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})`
);
@@ -873,8 +867,8 @@ export default {
'createPendingMessageAndSend',
messagePayload
);
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
this.$emitter.emit(BUS_EVENTS.MESSAGE_SENT);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private);
} catch (error) {
@@ -1200,7 +1194,7 @@ export default {
resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
},
onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file
@@ -47,9 +47,7 @@ export default {
},
methods: {
scrollToMessage() {
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.message.id,
});
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id });
},
},
};
@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<span class="text-sm font-medium text-slate-900 dark:text-slate-25">
{{ $t('SLA.EVENTS.TITLE') }}
@@ -167,29 +167,17 @@ export default {
};
},
mounted() {
this.$emitter.on(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
this.$emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.on(
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
},
destroyed() {
this.$emitter.off(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
this.onCmdSnoozeConversation
);
this.$emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.off(
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
bus.$off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation
);
@@ -1,257 +0,0 @@
<template>
<div @submit.prevent="onSubmit">
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<label :class="{ error: v$.teamId.$error }">
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL') }}
<select
v-model="formState.teamId"
:style="inputStyles"
@change="onChangeTeam"
>
<option v-for="item in teams" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.teamId.$error" class="message">
{{ teamError }}
</span>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL') }}
<select v-model="formState.assigneeId" :style="inputStyles">
<option v-for="item in assignees" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL') }}
<select v-model="formState.labelId" :style="inputStyles">
<option v-for="item in labels" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL') }}
<select v-model="formState.priority" :style="inputStyles">
<option v-for="item in priorities" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL') }}
<select v-model="formState.projectId" :style="inputStyles">
<option v-for="item in projects" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL') }}
<select v-model="formState.stateId" :style="inputStyles">
<option v-for="item in statuses" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
</label>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChangeTeam = event => {
formState.teamId = event.target.value;
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
getTeamEntities();
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>
@@ -1,92 +0,0 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<create-issue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<link-issue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const emits = defineEmits(['close']);
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emits('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>
@@ -1,123 +0,0 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlink-issue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<issue-header
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-ash-900">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-ash-800 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-ash-900">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-ash-800">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>
@@ -1,66 +0,0 @@
<template>
<div class="flex flex-row justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-0.5">
<woot-button
variant="clear"
color-scheme="secondary"
class="h-[24px]"
:is-loading="isUnlinking"
@click="unlinkIssue"
>
<fluent-icon
v-if="!isUnlinking"
icon="unlink"
size="12"
type="outline"
icon-lib="lucide"
/>
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const isUnlinking = inject('isUnlinking');
const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => {
emit('unlink-issue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>
@@ -1,143 +0,0 @@
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full bg-slate-50 dark:bg-slate-800 hover:bg-slate-75 dark:hover:bg-slate-800"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@click="onSelectIssue"
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking"
@click.prevent="linkIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emits = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emits('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>
@@ -1,141 +0,0 @@
<template>
<div class="relative" :class="{ group: linkedIssue }">
<woot-button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
variant="clear"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }}
</span>
</woot-button>
<issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
:show.sync="shouldShowPopup"
:on-close="closePopup"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<create-or-link-issue
:conversation="conversation"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
defineComponent({
name: 'Linear',
});
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(() =>
getters.getConversationById.value(props.conversationId)
);
const tooltipText = computed(() => {
return linkedIssue.value === null
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
}
};
const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
} finally {
isUnlinking.value = false;
}
};
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
loadLinkedIssue();
});
</script>
@@ -1,10 +0,0 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};
@@ -2,21 +2,12 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip';
import Button from 'dashboard/components/buttons/Button';
import i18n from 'dashboard/i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import MoreActions from '../MoreActions';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
}));
import { emitter } from 'shared/helpers/mitt';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
@@ -25,12 +16,6 @@ localVue.use(VTooltip);
localVue.component('fluent-icon', FluentIcon);
localVue.component('woot-button', Button);
localVue.prototype.$emitter = {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('MoveActions', () => {
@@ -44,6 +29,12 @@ describe('MoveActions', () => {
let moreActions = null;
beforeEach(() => {
window.bus = {
$emit: jest.fn(),
$on: jest.fn(),
$off: jest.fn(),
};
state = {
authenticated: true,
currentChat,
@@ -85,7 +76,7 @@ describe('MoveActions', () => {
it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith(
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
'This contact is blocked successfully. You will not be notified of any future conversations.',
undefined
@@ -111,7 +102,7 @@ describe('MoveActions', () => {
it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click');
expect(emitter.emit).toBeCalledWith(
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
'This contact is unblocked successfully.',
undefined
@@ -1,20 +0,0 @@
import { emitter } from 'shared/helpers/mitt';
import { onMounted, onBeforeUnmount } from 'vue';
// this will automatically add event listeners to the emitter
// and remove them when the component is destroyed
const useEmitter = (eventName, callback) => {
const cleanup = () => {
emitter.off(eventName, callback);
};
onMounted(() => {
emitter.on(eventName, callback);
});
onBeforeUnmount(cleanup);
return cleanup;
};
export { useEmitter };
@@ -1,5 +1,4 @@
import { getCurrentInstance } from 'vue';
import { emitter } from 'shared/helpers/mitt';
export const useTrack = () => {
const vm = getCurrentInstance();
@@ -9,5 +8,5 @@ export const useTrack = () => {
};
export function useAlert(message, action) {
emitter.emit('newToastMessage', message, action);
bus.$emit('newToastMessage', message, action);
}
@@ -1,51 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import { emitter } from 'shared/helpers/mitt';
import { useEmitter } from '../emitter';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
on: jest.fn(),
off: jest.fn(),
},
}));
describe('useEmitter', () => {
let wrapper;
const eventName = 'my-event';
const callback = jest.fn();
beforeEach(() => {
wrapper = shallowMount({
template: `
<div>
Hello world
</div>
`,
setup() {
return {
cleanup: useEmitter(eventName, callback),
};
},
});
});
afterEach(() => {
jest.resetAllMocks();
});
it('should add an event listener on mount', () => {
expect(emitter.on).toHaveBeenCalledWith(eventName, callback);
});
it('should remove the event listener when the component is unmounted', () => {
wrapper.destroy();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
it('should return the cleanup function', () => {
const cleanup = wrapper.vm.cleanup;
expect(typeof cleanup).toBe('function');
cleanup();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
});
-1
View File
@@ -30,5 +30,4 @@ export const FEATURE_FLAGS = {
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
INBOUND_EMAILS: 'inbound_emails',
IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
};
@@ -1,140 +0,0 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { differenceInSeconds } from 'date-fns';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
const MAX_DISCONNECT_SECONDS = 10800;
class ReconnectService {
constructor(store, router) {
this.store = store;
this.router = router;
this.disconnectTime = null;
this.setupEventListeners();
}
disconnect = () => this.removeEventListeners();
setupEventListeners = () => {
window.addEventListener('online', this.handleOnlineEvent);
emitter.on(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect);
emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect);
};
removeEventListeners = () => {
window.removeEventListener('online', this.handleOnlineEvent);
emitter.off(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect);
emitter.off(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect);
};
getSecondsSinceDisconnect = () =>
this.disconnectTime
? Math.max(differenceInSeconds(new Date(), this.disconnectTime), 0)
: 0;
// Force reload if the user is disconnected for more than 3 hours
handleOnlineEvent = () => {
if (this.getSecondsSinceDisconnect() >= MAX_DISCONNECT_SECONDS) {
window.location.reload();
}
};
fetchConversations = async () => {
await this.store.dispatch('updateChatListFilters', {
page: null,
updatedWithin: this.getSecondsSinceDisconnect(),
});
await this.store.dispatch('fetchAllConversations');
// Reset the updatedWithin in the store chat list filter after fetching conversations when the user is reconnected
await this.store.dispatch('updateChatListFilters', {
updatedWithin: null,
});
};
fetchFilteredOrSavedConversations = async queryData => {
await this.store.dispatch('fetchFilteredConversations', {
queryData,
page: 1,
});
};
fetchConversationsOnReconnect = async () => {
const {
getAppliedConversationFiltersQuery,
'customViews/getActiveConversationFolder': activeFolder,
} = this.store.getters;
const query = getAppliedConversationFiltersQuery?.payload?.length
? getAppliedConversationFiltersQuery
: activeFolder?.query;
if (query) {
await this.fetchFilteredOrSavedConversations(query);
} else {
await this.fetchConversations();
}
};
fetchConversationMessagesOnReconnect = async () => {
const { conversation_id: conversationId } = this.router.currentRoute.params;
if (conversationId) {
await this.store.dispatch('syncActiveConversationMessages', {
conversationId: Number(conversationId),
});
}
};
fetchNotificationsOnReconnect = async filter => {
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
);
await Promise.all([
this.store.dispatch('labels/revalidate', { newKey: label }),
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
this.store.dispatch('teams/revalidate', { newKey: team }),
]);
};
handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.name;
if (isAConversationRoute(currentRoute, true)) {
await this.fetchConversationsOnReconnect();
await this.fetchConversationMessagesOnReconnect();
} else if (isAInboxViewRoute(currentRoute, true)) {
await this.fetchNotificationsOnReconnect(
this.store.getters['notifications/getNotificationFilters']
);
} else if (isNotificationRoute(currentRoute)) {
await this.fetchNotificationsOnReconnect();
}
};
setConversationLastMessageId = async () => {
const { conversation_id: conversationId } = this.router.currentRoute.params;
if (conversationId) {
await this.store.dispatch('setConversationLastMessageId', {
conversationId: Number(conversationId),
});
}
};
onDisconnect = () => {
this.disconnectTime = new Date();
this.setConversationLastMessageId();
};
onReconnect = async () => {
await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
};
}
export default ReconnectService;
+27 -9
View File
@@ -1,8 +1,6 @@
import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
@@ -34,14 +32,34 @@ class ActionCableConnector extends BaseActionCableConnector {
};
}
// eslint-disable-next-line class-methods-use-this
onReconnect = () => {
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
this.syncActiveConversationMessages();
};
// eslint-disable-next-line class-methods-use-this
onDisconnected = () => {
emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
this.setActiveConversationLastMessageId();
};
setActiveConversationLastMessageId = () => {
const {
params: { conversation_id },
} = this.app.$route;
if (conversation_id) {
this.app.$store.dispatch('setConversationLastMessageId', {
conversationId: Number(conversation_id),
});
}
};
syncActiveConversationMessages = () => {
const {
params: { conversation_id },
} = this.app.$route;
if (conversation_id) {
this.app.$store.dispatch('syncActiveConversationMessages', {
conversationId: Number(conversation_id),
});
}
};
isAValidEvent = data => {
@@ -159,8 +177,8 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
fetchConversationStats = () => {
emitter.emit('fetch_conversation_stats');
emitter.emit('fetch_overview_reports');
bus.$emit('fetch_conversation_stats');
bus.$emit('fetch_overview_reports');
};
onContactDelete = data => {
@@ -189,7 +207,7 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => {
emitter.emit('fetch_overview_reports');
bus.$emit('fetch_overview_reports');
};
onCacheInvalidate = data => {
+1 -2
View File
@@ -87,8 +87,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
const allowedInboxTypes = [INBOX_TYPES.FB, INBOX_TYPES.EMAIL];
if (allowedInboxTypes.includes(type) && reauthorizationRequired) {
if (type === INBOX_TYPES.FB && reauthorizationRequired) {
return 'warning';
}
return '';
@@ -1,7 +1,6 @@
/* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth';
import { emitter } from 'shared/helpers/mitt';
export const verifyServiceWorkerExistence = (callback = () => {}) => {
if (!('serviceWorker' in navigator)) {
@@ -69,7 +68,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
onSuccess();
})
.catch(() => {
emitter.emit(
window.bus.$emit(
'newToastMessage',
'This browser does not support desktop notification'
);
@@ -78,7 +77,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
emitter.emit(
window.bus.$emit(
'newToastMessage',
'This browser does not support desktop notification'
);
@@ -109,14 +109,5 @@ export const getConversationDashboardRoute = routeName => {
}
};
export const isAInboxViewRoute = (routeName, includeBase = false) => {
const baseRoutes = ['inbox_view'];
const extendedRoutes = ['inbox_view_conversation'];
const routeNames = includeBase
? [...baseRoutes, ...extendedRoutes]
: extendedRoutes;
return routeNames.includes(routeName);
};
export const isNotificationRoute = routeName =>
routeName === 'notifications_index';
export const isAInboxViewRoute = routeName =>
['inbox_view_conversation'].includes(routeName);
@@ -1,6 +1,5 @@
import AnalyticsHelper from './AnalyticsHelper';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { emitter } from 'shared/helpers/mitt';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@@ -9,7 +8,7 @@ export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user);
});
};
@@ -35,12 +34,12 @@ const initializeAudioAlerts = user => {
};
export const initializeChatwootEvents = () => {
emitter.on(CHATWOOT_RESET, () => {
window.bus.$on(CHATWOOT_RESET, () => {
if (window.$chatwoot) {
window.$chatwoot.reset();
}
});
emitter.on(CHATWOOT_SET_USER, ({ user }) => {
window.bus.$on(CHATWOOT_SET_USER, ({ user }) => {
if (window.$chatwoot) {
window.$chatwoot.setUser(user.email, {
avatar_url: user.avatar_url,
@@ -66,3 +66,29 @@ export const snoozedReopenTime = snoozedUntil => {
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
);
return shortenTime;
};
@@ -1,342 +0,0 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { differenceInSeconds } from 'date-fns';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import ReconnectService from 'dashboard/helper/ReconnectService';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
}));
jest.mock('date-fns', () => ({
differenceInSeconds: jest.fn(),
}));
jest.mock('dashboard/helper/routeHelpers', () => ({
isAConversationRoute: jest.fn(),
isAInboxViewRoute: jest.fn(),
isNotificationRoute: jest.fn(),
}));
const storeMock = {
dispatch: jest.fn(),
getters: {
getAppliedConversationFiltersQuery: [],
'customViews/getActiveConversationFolder': { query: {} },
'notifications/getNotificationFilters': {},
},
};
const routerMock = {
currentRoute: {
name: '',
params: { conversation_id: null },
},
};
describe('ReconnectService', () => {
let reconnectService;
beforeEach(() => {
window.addEventListener = jest.fn();
window.removeEventListener = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: jest.fn() },
});
reconnectService = new ReconnectService(storeMock, routerMock);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with store, router, and setup event listeners', () => {
expect(reconnectService.store).toBe(storeMock);
expect(reconnectService.router).toBe(routerMock);
expect(window.addEventListener).toHaveBeenCalledWith(
'online',
reconnectService.handleOnlineEvent
);
expect(emitter.on).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT,
reconnectService.onReconnect
);
expect(emitter.on).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_DISCONNECT,
reconnectService.onDisconnect
);
});
});
describe('disconnect', () => {
it('should remove event listeners', () => {
reconnectService.disconnect();
expect(window.removeEventListener).toHaveBeenCalledWith(
'online',
reconnectService.handleOnlineEvent
);
expect(emitter.off).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT,
reconnectService.onReconnect
);
expect(emitter.off).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_DISCONNECT,
reconnectService.onDisconnect
);
});
});
describe('getSecondsSinceDisconnect', () => {
it('should return 0 if disconnectTime is null', () => {
reconnectService.disconnectTime = null;
expect(reconnectService.getSecondsSinceDisconnect()).toBe(0);
});
it('should return the number of seconds since disconnect', () => {
reconnectService.disconnectTime = new Date();
differenceInSeconds.mockReturnValue(100);
expect(reconnectService.getSecondsSinceDisconnect()).toBe(100);
});
});
describe('handleOnlineEvent', () => {
it('should reload the page if disconnected for more than 3 hours', () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(10801);
reconnectService.handleOnlineEvent();
expect(window.location.reload).toHaveBeenCalled();
});
it('should not reload the page if disconnected for less than 3 hours', () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(10799);
reconnectService.handleOnlineEvent();
expect(window.location.reload).not.toHaveBeenCalled();
});
});
describe('fetchConversations', () => {
it('should dispatch updateChatListFilters and fetchAllConversations', async () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
page: null,
updatedWithin: 100,
});
expect(storeMock.dispatch).toHaveBeenCalledWith('fetchAllConversations');
});
it('should dispatch updateChatListFilters and reset updatedWithin', async () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
updatedWithin: null,
});
});
});
describe('fetchFilteredOrSavedConversations', () => {
it('should dispatch fetchFilteredConversations', async () => {
const payload = { test: 'data' };
await reconnectService.fetchFilteredOrSavedConversations(payload);
expect(storeMock.dispatch).toHaveBeenCalledWith(
'fetchFilteredConversations',
{ queryData: payload, page: 1 }
);
});
});
describe('fetchConversationsOnReconnect', () => {
it('should fetch filtered or saved conversations if query exists', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
},
],
};
const spy = jest.spyOn(
reconnectService,
'fetchFilteredOrSavedConversations'
);
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalledWith(
storeMock.getters.getAppliedConversationFiltersQuery
);
});
it('should fetch all conversations if no query exists', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = [];
storeMock.getters['customViews/getActiveConversationFolder'] = {
query: null,
};
const spy = jest.spyOn(reconnectService, 'fetchConversations');
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalled();
});
it('should fetch filtered or saved conversations if active folder query exists and no applied query', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = [];
storeMock.getters['customViews/getActiveConversationFolder'] = {
query: { test: 'activeFolderQuery' },
};
const spy = jest.spyOn(
reconnectService,
'fetchFilteredOrSavedConversations'
);
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalledWith({ test: 'activeFolderQuery' });
});
});
describe('fetchConversationMessagesOnReconnect', () => {
it('should dispatch syncActiveConversationMessages if conversationId exists', async () => {
routerMock.currentRoute.params.conversation_id = 1;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'syncActiveConversationMessages',
{ conversationId: 1 }
);
});
it('should not dispatch syncActiveConversationMessages if conversationId does not exist', async () => {
routerMock.currentRoute.params.conversation_id = null;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'syncActiveConversationMessages',
expect.anything()
);
});
});
describe('fetchNotificationsOnReconnect', () => {
it('should dispatch notifications/index', async () => {
const filter = { test: 'filter' };
await reconnectService.fetchNotificationsOnReconnect(filter);
expect(storeMock.dispatch).toHaveBeenCalledWith('notifications/index', {
...filter,
page: 1,
});
});
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: 'labelKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: 'inboxKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
});
});
describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true);
const spyConversations = jest.spyOn(
reconnectService,
'fetchConversationsOnReconnect'
);
const spyMessages = jest.spyOn(
reconnectService,
'fetchConversationMessagesOnReconnect'
);
await reconnectService.handleRouteSpecificFetch();
expect(spyConversations).toHaveBeenCalled();
expect(spyMessages).toHaveBeenCalled();
});
it('should fetch notifications if current route is an inbox view route', async () => {
isAInboxViewRoute.mockReturnValue(true);
const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
await reconnectService.handleRouteSpecificFetch();
expect(spy).toHaveBeenCalled();
});
it('should fetch notifications if current route is a notification route', async () => {
isNotificationRoute.mockReturnValue(true);
const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
await reconnectService.handleRouteSpecificFetch();
expect(spy).toHaveBeenCalled();
});
});
describe('setConversationLastMessageId', () => {
it('should dispatch setConversationLastMessageId if conversationId exists', async () => {
routerMock.currentRoute.params.conversation_id = 1;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'setConversationLastMessageId',
{ conversationId: 1 }
);
});
it('should not dispatch setConversationLastMessageId if conversationId does not exist', async () => {
routerMock.currentRoute.params.conversation_id = null;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'setConversationLastMessageId',
expect.anything()
);
});
});
describe('onDisconnect', () => {
it('should set disconnectTime and call setConversationLastMessageId', () => {
reconnectService.setConversationLastMessageId = jest.fn();
reconnectService.onDisconnect();
expect(reconnectService.disconnectTime).toBeInstanceOf(Date);
expect(reconnectService.setConversationLastMessageId).toHaveBeenCalled();
});
});
describe('onReconnect', () => {
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
reconnectService.handleRouteSpecificFetch = jest.fn();
reconnectService.revalidateCaches = jest.fn();
await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
);
});
});
});
@@ -186,12 +186,4 @@ describe('isAInboxViewRoute', () => {
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
expect(isAInboxViewRoute('inbox_conversation')).toBe(false);
});
it('returns true if base inbox view route name is provided and includeBase is true', () => {
expect(isAInboxViewRoute('inbox_view', true)).toBe(true);
});
it('returns false if base inbox view route name is provided and includeBase is false', () => {
expect(isAInboxViewRoute('inbox_view')).toBe(false);
});
});
@@ -5,6 +5,8 @@ import {
findStartOfNextMonth,
findNextDay,
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
@@ -107,4 +109,41 @@ describe('#Snooze Helpers', () => {
expect(findNextDay(today)).toEqual(nextDay);
});
});
describe('snoozedReopenTimeToTimestamp', () => {
it('should return timestamp if snoozedUntil is not nil', () => {
expect(snoozedReopenTimeToTimestamp('2023-06-07T09:00:00.000Z')).toEqual(
1686128400
);
});
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTimeToTimestamp(null)).toEqual(null);
});
});
describe('shortenSnoozeTime', () => {
it('should return shortened time if snoozedUntil is not nil and day is passed', () => {
expect(shortenSnoozeTime('1 day')).toEqual('1d');
});
it('should return shortened time if snoozedUntil is not nil and month is passed', () => {
expect(shortenSnoozeTime('1 month')).toEqual('1mo');
});
it('should return shortened time if snoozedUntil is not nil and year is passed', () => {
expect(shortenSnoozeTime('1 year')).toEqual('1y');
});
it('should return shortened time if snoozedUntil is not nil and hour is passed', () => {
expect(shortenSnoozeTime('1 hour')).toEqual('1h');
});
it('should return shortened time if snoozedUntil is not nil and minutes is passed', () => {
expect(shortenSnoozeTime('1 minutes')).toEqual('1m');
});
it('should return nil if snoozedUntil is nil', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
});
@@ -33,7 +33,7 @@
"NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_SUCCESFUL": "Teams assiged successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again."
}
}
@@ -95,9 +95,7 @@
},
"NETWORK": {
"NOTIFICATION": {
"OFFLINE": "Offline",
"RECONNECTING": "Reconnecting...",
"RECONNECT_SUCCESS": "Reconnected"
"OFFLINE": "Offline"
},
"BUTTON": {
"REFRESH": "Refresh"
@@ -8,9 +8,8 @@
"NO_NOTIFICATIONS": "No notifications",
"NOTE": "Notifications from all subscribed inboxes",
"NO_MESSAGES_AVAILABLE": "Oops! Not able to fetch messages",
"SNOOZED_UNTIL": "Snoozed until",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week"
"SNOOZED_UNTIL": "We’ll remind you after {time}",
"SNOOZED_ENDS": "We won’t notify you again"
},
"ACTION_HEADER": {
"SNOOZE": "Snooze notification",
@@ -18,14 +17,11 @@
"BACK": "Back"
},
"TYPES": {
"CONVERSATION_MENTION": "You have been mentioned in a conversation",
"CONVERSATION_CREATION": "New conversation created",
"CONVERSATION_ASSIGNMENT": "A conversation has been assigned to you",
"ASSIGNED_CONVERSATION_NEW_MESSAGE": "New message in an assigned conversation",
"PARTICIPATING_CONVERSATION_NEW_MESSAGE": "New message in a conversation you are participating in",
"SLA_MISSED_FIRST_RESPONSE": "SLA target first response missed for conversation",
"SLA_MISSED_NEXT_RESPONSE": "SLA target next response missed for conversation",
"SLA_MISSED_RESOLUTION": "SLA target resolution missed for conversation"
"CONVERSATION_MENTION": "Mention",
"CONVERSATION_ASSIGNMENT": "Assigned to you",
"SLA_MISSED_FIRST_RESPONSE": "SLA breach",
"SLA_MISSED_NEXT_RESPONSE": "SLA breach",
"SLA_MISSED_RESOLUTION": "SLA breach"
},
"MENU_ITEM": {
"MARK_AS_READ": "Mark as read",
@@ -2,8 +2,7 @@
"INBOX_MGMT": {
"HEADER": "Inboxes",
"SIDEBAR_TXT": "<p><b>Inbox</b></p> <p> When you connect a website or a facebook Page to Chatwoot, it is called an <b>Inbox</b>. You can have unlimited inboxes in your Chatwoot account. </p><p> Click on <b>Add Inbox</b> to connect a website or a Facebook Page. </p><p> In the Dashboard, you can see all the conversations from all your inboxes in a single place and respond to them under the `Conversations` tab. </p><p> You can also see conversations specific to an inbox by clicking on the inbox name on the left pane of the dashboard. </p>",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"RECONNECTION_REQUIRED": "Your inbox is disconnected, you will not receive any new messages. <a class=\"underline\" href=\"%{actionUrl}\">Click here</a> to reconnect.",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -366,7 +365,6 @@
"TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address",
"SIGN_IN": "Sign in with Microsoft",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
}
@@ -739,4 +737,4 @@
"OTHER_PROVIDERS": "Other Providers"
}
}
}
}
@@ -203,87 +203,6 @@
"API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later"
}
},
"LINEAR": {
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
"LOADING": "Fetching linear issues...",
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
"CREATE": "Create",
"LINK": {
"SEARCH": "Search issues",
"SELECT": "Select issue",
"TITLE": "Link",
"EMPTY_LIST": "No linear issues found",
"LOADING": "Loading",
"ERROR": "There was an error fetching the linear issues, please try again",
"LINK_SUCCESS": "Issue linked successfully",
"LINK_ERROR": "There was an error linking the issue, please try again",
"LINK_TITLE": "Conversation (#%{conversationId}) with %{name}"
},
"ADD_OR_LINK": {
"TITLE": "Create/link linear issue",
"DESCRIPTION": "Create Linear issues from conversations, or link existing ones for seamless tracking.",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter title",
"REQUIRED_ERROR": "Title is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter description"
},
"TEAM": {
"LABEL": "Team",
"PLACEHOLDER": "Select team",
"SEARCH": "Search team",
"REQUIRED_ERROR": "Team is required"
},
"ASSIGNEE": {
"LABEL": "Assignee",
"PLACEHOLDER": "Select assignee",
"SEARCH": "Search assignee"
},
"PRIORITY": {
"LABEL": "Priority",
"PLACEHOLDER": "Select priority",
"SEARCH": "Search priority"
},
"LABEL": {
"LABEL": "Label",
"PLACEHOLDER": "Select label",
"SEARCH": "Search label"
},
"STATUS": {
"LABEL": "Status",
"PLACEHOLDER": "Select status",
"SEARCH": "Search status"
},
"PROJECT": {
"LABEL": "Project",
"PLACEHOLDER": "Select project",
"SEARCH": "Search project"
}
},
"CREATE": "Create",
"CANCEL": "Cancel",
"CREATE_SUCCESS": "Issue created successfully",
"CREATE_ERROR": "There was an error creating the issue, please try again",
"LOADING_TEAM_ERROR": "There was an error fetching the teams, please try again",
"LOADING_TEAM_ENTITIES_ERROR": "There was an error fetching the team entities, please try again"
},
"ISSUE": {
"STATUS": "Status",
"PRIORITY": "Priority",
"ASSIGNEE": "Assignee",
"LABELS": "Labels",
"CREATED_AT": "Created at %{createdAt}"
},
"UNLINK": {
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
}
}
}
}
@@ -274,7 +274,7 @@
"SLA": "SLA",
"BETA": "Beta",
"REPORTS_OVERVIEW": "Overview",
"REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services",
"HELP_CENTER": {
"TITLE": "Help Center",
"ALL_ARTICLES": "All Articles",
@@ -15,7 +15,7 @@ export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
];
const slugifyChannel = name =>
name?.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
name.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
export const isEditorHotKeyEnabled = (uiSettings, key) => {
const {
@@ -70,8 +70,6 @@ export default {
this.updateUISettings({ [key]: !this.isContactSidebarItemOpen(key) });
},
setSignatureFlagForInbox(channelType, value) {
if (!channelType) return;
channelType = slugifyChannel(channelType);
this.updateUISettings({
[`${channelType}_signature_enabled`]: value,
@@ -110,11 +110,11 @@ export default {
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
this.$emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
this.$emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
methods: {
@@ -11,7 +11,6 @@ import {
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from './commandBarActions';
@@ -38,7 +37,7 @@ export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: () => emitter.emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
handler: () => bus.$emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
@@ -48,7 +47,7 @@ export const OPEN_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => emitter.emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
handler: () => bus.$emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
@@ -1,5 +1,4 @@
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import {
CMD_MUTE_CONVERSATION,
@@ -27,7 +26,7 @@ export const OPEN_CONVERSATION_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => emitter.emit(CMD_RESOLVE_CONVERSATION),
handler: () => bus.$emit(CMD_RESOLVE_CONVERSATION),
},
];
@@ -38,7 +37,7 @@ export const createSnoozeHandlers = (busEventName, parentId, section) => {
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
handler: () => bus.$emit(busEventName, option),
}));
};
@@ -62,7 +61,7 @@ export const RESOLVED_CONVERSATION_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_REOPEN_CONVERSATION,
handler: () => emitter.emit(CMD_REOPEN_CONVERSATION),
handler: () => bus.$emit(CMD_REOPEN_CONVERSATION),
},
];
@@ -71,7 +70,7 @@ export const SEND_TRANSCRIPT_ACTION = {
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SEND_TRANSCRIPT,
handler: () => emitter.emit(CMD_SEND_TRANSCRIPT),
handler: () => bus.$emit(CMD_SEND_TRANSCRIPT),
};
export const UNMUTE_ACTION = {
@@ -79,7 +78,7 @@ export const UNMUTE_ACTION = {
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_UNMUTE_CONVERSATION,
handler: () => emitter.emit(CMD_UNMUTE_CONVERSATION),
handler: () => bus.$emit(CMD_UNMUTE_CONVERSATION),
};
export const MUTE_ACTION = {
@@ -87,5 +86,5 @@ export const MUTE_ACTION = {
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_MUTE_CONVERSATION,
handler: () => emitter.emit(CMD_MUTE_CONVERSATION),
handler: () => bus.$emit(CMD_MUTE_CONVERSATION),
};
@@ -1,6 +1,5 @@
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import { CMD_AI_ASSIST } from './commandBarBusEvents';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -319,7 +318,7 @@ export default {
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
handler: () => bus.$emit(CMD_AI_ASSIST, item.key),
}));
return [
{
@@ -2,7 +2,6 @@ import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
@@ -22,7 +21,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
@@ -31,7 +30,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
@@ -40,7 +39,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
@@ -49,7 +48,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
@@ -58,7 +57,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export default {
@@ -145,6 +145,7 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue';
export default {
components: {
AccordionItem,
@@ -19,12 +19,12 @@ const initiatedAt = computed(
() => props.conversationAttributes.initiated_at?.timestamp
);
const browserInfo = computed(() => props.conversationAttributes.browser);
const browserInfo = props.conversationAttributes.browser;
const browserName = computed(() => {
if (!browserInfo.value) return '';
if (!browserInfo) return '';
const { browser_name: name = '', browser_version: version = '' } =
browserInfo.value;
browserInfo;
return `${name} ${version}`;
});
@@ -33,9 +33,9 @@ const browserLanguage = computed(() =>
);
const platformName = computed(() => {
if (!browserInfo.value) return '';
if (!browserInfo) return '';
const { platform_name: name = '', platform_version: version = '' } =
browserInfo.value;
browserInfo;
return `${name} ${version}`;
});
@@ -169,7 +169,7 @@ export default {
after: messageId,
})
.then(() => {
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
});
} else {
this.$store.dispatch('clearSelectedState');
@@ -271,10 +271,7 @@ export default {
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
this.$emitter.emit(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.showConversationModal
);
bus.$emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, this.showConversationModal);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
@@ -265,7 +265,7 @@ export default {
},
mounted() {
this.$emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
const slug = this.$route.params.portalSlug;
if (slug) this.lastActivePortalSlug = slug;
@@ -273,7 +273,7 @@ export default {
this.fetchPortalAndItsCategories();
},
beforeDestroy() {
this.$emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
},
updated() {
const slug = this.$route.params.portalSlug;
@@ -35,7 +35,7 @@ const locales = computed(() => {
});
const allowedLocales = computed(() => {
return Object.keys(locales.value).map(key => {
return locales.value[key].code;
return this.locales.value[key].code;
});
});
@@ -72,7 +72,7 @@ async function updatePortalLocales({
function changeDefaultLocale({ localeCode }) {
updatePortalLocales({
newAllowedLocales: allowedLocales.value,
allowedLocales: allowedLocales.value,
defaultLocale: localeCode,
messageKey: 'CHANGE_DEFAULT_LOCALE',
});
@@ -90,7 +90,7 @@ function deletePortalLocale({ localeCode }) {
const defaultLocale = currentPortal.value?.meta.default_locale;
updatePortalLocales({
newAllowedLocales: updatedLocales,
allowedLocales: updatedLocales,
defaultLocale,
messageKey: 'DELETE_LOCALE',
});
@@ -47,7 +47,6 @@
<script>
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -104,13 +103,6 @@ export default {
return !this.uiFlags.isFetching && !this.notifications.length;
},
},
watch: {
inboxFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('notifications/updateNotificationFilters', newVal);
}
},
},
mounted() {
this.setSavedFilter();
this.fetchNotifications();
@@ -198,10 +190,6 @@ export default {
this.status = status;
this.type = type;
this.sortOrder = sortBy || wootConstants.INBOX_SORT_BY.NEWEST;
this.$store.dispatch(
'notifications/setNotificationFilters',
this.inboxFilters
);
},
},
};
@@ -1,5 +1,5 @@
<template>
<div class="h-full w-full md:w-[calc(100%-360px)]">
<div class="h-full flex-1 min-w-0">
<div v-if="showEmptyState" class="flex w-full h-full">
<inbox-empty-state
:empty-state-message="$t('INBOX.LIST.NO_MESSAGES_AVAILABLE')"
@@ -140,7 +140,7 @@ export default {
this.$store
.dispatch('setActiveChat', { data: selectedConversation })
.then(() => {
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
});
},
findConversation() {
@@ -1,7 +1,7 @@
<template>
<div
role="button"
class="flex flex-col ltr:pl-5 rtl:pl-3 rtl:pr-5 ltr:pr-3 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
class="flex flex-col ltr:pl-5 rtl:pl-4 rtl:pr-5 ltr:pr-4 gap-2.5 py-3 w-full border-b border-slate-50 dark:border-slate-800/50 hover:bg-slate-25 dark:hover:bg-slate-800 cursor-pointer"
:class="
active
? 'bg-slate-25 dark:bg-slate-800 click-animation'
@@ -10,20 +10,13 @@
@contextmenu="openContextMenu($event)"
@click="openConversation(notificationItem)"
>
<div class="flex relative items-center justify-between w-full">
<div
class="flex flex-row justify-between relative items-center w-full gap-1.5"
>
<div
v-if="isUnread"
class="absolute ltr:-left-3.5 rtl:-right-3.5 flex w-2 h-2 rounded bg-woot-500 dark:bg-woot-500"
/>
<InboxNameAndId :inbox="inbox" :conversation-id="primaryActor.id" />
<div class="flex gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
</div>
</div>
<div class="flex flex-row justify-between items-center w-full gap-2">
<Thumbnail
v-if="assigneeMeta"
:src="assigneeMeta.thumbnail"
@@ -31,21 +24,72 @@
size="16px"
/>
<span
class="flex-1 text-slate-800 dark:text-slate-50 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
class="flex-1 overflow-hidden text-sm text-slate-900 dark:text-white text-ellipsis whitespace-nowrap"
:class="isUnread ? 'font-medium' : 'font-normal'"
>
{{ pushTitle }}
</span>
<span
class="font-medium text-slate-600 dark:text-slate-300 text-xs whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
<div v-if="snoozedUntilTime" class="flex items-center">
<span class="text-woot-500 dark:text-woot-500 text-xs font-medium">
{{ snoozedDisplayText }}
</span>
<div
ref="inboxCardInfo"
v-resize="onResize"
class="flex flex-row items-center justify-between w-full gap-2"
>
<div
v-if="snoozedUntilTime || hasLastSnoozed"
class="flex flex-row items-center flex-1 min-w-0 gap-1"
>
<fluent-icon
:icon="hasLastSnoozed ? 'snooze-timer' : 'sharp-timer'"
type="outline"
class="flex-shrink-0 text-woot-500 dark:text-woot-500"
size="16"
/>
<span
class="text-xs font-medium truncate text-woot-500 dark:text-woot-500"
>
{{ snoozedDisplayText }}
</span>
</div>
<div
v-else
class="flex flex-row items-center justify-start min-w-0 gap-2"
>
<inbox-card-info
:inbox="inbox"
:conversation-id="primaryActor.id"
:conversation-labels="primaryActor.labels"
:parent-element-width="inboxCardInfoElementWidth"
/>
<div
v-show="hasNotificationType"
class="flex flex-row items-center gap-0.5 w-fit min-w-0"
>
<fluent-icon
:icon="notificationDetails.icon"
type="outline"
class="flex-shrink-0"
:class="notificationDetails.color"
size="16"
/>
<span
class="text-xs font-medium truncate"
:class="notificationDetails.color"
>
{{ notificationDetails.text }}
</span>
</div>
</div>
<div class="flex items-center justify-end gap-2">
<PriorityIcon :priority="primaryActor.priority" />
<StatusIcon :status="primaryActor.status" />
<span
class="text-xs font-medium text-slate-500 dark:text-slate-400 whitespace-nowrap"
>
{{ lastActivityAt }}
</span>
</div>
</div>
<inbox-context-menu
v-if="isContextMenuOpen"
@@ -60,18 +104,23 @@
<script>
import PriorityIcon from './PriorityIcon.vue';
import StatusIcon from './StatusIcon.vue';
import InboxNameAndId from './InboxNameAndId.vue';
import InboxCardInfo from './InboxCardInfo.vue';
import InboxContextMenu from './InboxContextMenu.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import timeMixin from 'dashboard/mixins/time';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import {
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
} from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { NOTIFICATION_TYPES_MAPPING } from './helpers/constants';
export default {
components: {
PriorityIcon,
InboxContextMenu,
StatusIcon,
InboxNameAndId,
InboxCardInfo,
Thumbnail,
},
mixins: [timeMixin],
@@ -89,6 +138,7 @@ export default {
return {
isContextMenuOpen: false,
contextMenuPosition: { x: null, y: null },
inboxCardInfoElementWidth: 0,
};
},
computed: {
@@ -110,15 +160,31 @@ export default {
return this.meta?.assignee;
},
pushTitle() {
return this.$t(
`INBOX.TYPES.${this.notificationItem.notification_type.toUpperCase()}`
);
return this.notificationItem?.push_message_body;
},
hasNotificationType() {
return this.notificationItem?.notification_type;
},
notificationDetails() {
const { notification_type: notificationType = '' } =
this.notificationItem || {};
const upperCaseType = notificationType?.toUpperCase();
const text = NOTIFICATION_TYPES_MAPPING[upperCaseType]
? this.$t(`INBOX.TYPES.${upperCaseType}`)
: '';
const [icon, color] = NOTIFICATION_TYPES_MAPPING[upperCaseType] || [
'',
'text-woot-500 dark:text-woot-500',
];
return { text, icon, color };
},
lastActivityAt() {
const dynamicTime = this.dynamicTime(
this.notificationItem?.last_activity_at
);
return this.shortTimestamp(dynamicTime, true);
return this.shortTimestamp(dynamicTime);
},
menuItems() {
const items = [
@@ -143,13 +209,23 @@ export default {
},
snoozedUntilTime() {
const { snoozed_until: snoozedUntil } = this.notificationItem;
return snoozedUntil;
if (!snoozedUntil) {
return null;
}
const timestamp = snoozedReopenTimeToTimestamp(snoozedUntil);
return shortenSnoozeTime(this.dynamicTime(timestamp));
},
hasLastSnoozed() {
return this.notificationItem?.meta?.last_snoozed_at;
},
snoozedDisplayText() {
if (this.hasLastSnoozed) {
return this.$t('INBOX.LIST.SNOOZED_ENDS');
}
if (this.snoozedUntilTime) {
return `${this.$t('INBOX.LIST.SNOOZED_UNTIL')} ${snoozedReopenTime(
this.snoozedUntilTime
)}`;
return this.$t('INBOX.LIST.SNOOZED_UNTIL', {
time: this.shortTimestamp(this.snoozedUntilTime),
});
}
return '';
},
@@ -158,6 +234,9 @@ export default {
this.closeContextMenu();
},
methods: {
onResize(entry) {
this.inboxCardInfoElementWidth = entry.contentRect.width;
},
openConversation(notification) {
const {
id,
@@ -0,0 +1,160 @@
<template>
<div
class="flex items-center w-auto justify-start rounded-[4px] border border-slate-75 dark:border-slate-700/50 divide-x divide-slate-75 dark:divide-slate-700/50 bg-none overflow-hidden"
>
<!-- Inbox icon -->
<div
v-if="inbox"
ref="inboxIcon"
v-tooltip.right="inbox.name"
class="flex items-center gap-0.5 py-0.5 px-1.5"
>
<fluent-icon
class="flex-shrink-0 text-slate-700 dark:text-slate-200"
:icon="inboxIcon"
type="outline"
size="14"
/>
</div>
<!-- Conversation ID -->
<span
class="flex items-center py-0.5 px-1.5 font-medium text-slate-700 dark:text-slate-200 text-xs"
>
{{ conversationId }}
</span>
<!-- Labels display logic -->
<div
v-for="(label, index) in activeLabels"
v-show="index < visibleLabels"
:key="label.id"
class="flex items-center truncate justify-start gap-1 py-0.5 px-1.5"
>
<span
v-if="label.title"
:style="{ background: label.color }"
class="flex-shrink-0 w-2 h-2 rounded-sm"
/>
<span
:title="label.title"
class="text-xs font-medium truncate text-slate-700 dark:text-slate-200"
>
{{ label.title }}
</span>
</div>
<!-- Label count display logic -->
<span
v-if="activeLabels.length > visibleLabels"
:title="hiddenLabelsTitle"
class="flex items-center py-0.5 px-1.5 font-medium text-slate-700 dark:text-slate-200 text-xs"
>
+{{ activeLabels.length - visibleLabels }}
</span>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
import { getInboxClassByType } from 'dashboard/helper/inbox';
const MAX_LABELS = 6;
const MIN_LABELS = 1;
const BASE_WIDTH = 360;
const TOTAL_PADDING = 36;
const EFFECTIVE_WIDTH = BASE_WIDTH - TOTAL_PADDING;
// 24 is the padding + gap + label color width
const LABEL_ITEM_PADDING = 24;
const AVG_CHAR_WIDTH = 5.2;
// Clamp function to limit the number of labels to be displayed
const clamp = (value, min, max) => Math.min(Math.max(min, value), max);
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
conversationLabels: {
type: Array,
default: () => [],
},
parentElementWidth: {
type: Number,
default: 0,
},
},
data() {
return {
visibleLabels: 1,
};
},
computed: {
...mapGetters({ accountLabels: 'labels/getLabels' }),
inboxIcon() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
return getInboxClassByType(type, phoneNumber);
},
activeLabels() {
return this.accountLabels?.filter(({ title }) =>
this.conversationLabels?.includes(title)
);
},
hiddenLabelsTitle() {
if (this.activeLabels?.length <= this.visibleLabels) {
return '';
}
return (
this.activeLabels
.slice(this.visibleLabels)
.map(label => label.title)
.join(', ') || ''
);
},
},
watch: {
parentElementWidth() {
if (this.activeLabels.length > 1) {
this.updateLabelWidths();
}
},
},
methods: {
updateLabelWidths() {
this.$nextTick(() => {
const containerWidth = this.parentElementWidth || EFFECTIVE_WIDTH;
if (containerWidth <= EFFECTIVE_WIDTH) {
this.visibleLabels = 1;
return;
}
let widthAvailable = containerWidth - EFFECTIVE_WIDTH;
// go through each label and calculate the width required
let numberOfLabels = this.activeLabels.reduce(
(acc, label) => {
const widthRequired =
label.title.length * AVG_CHAR_WIDTH + LABEL_ITEM_PADDING;
if (acc.widthAvailable >= widthRequired) {
acc.widthAvailable -= widthRequired;
acc.count += 1;
}
return acc;
},
{ count: 0, widthAvailable }
).count;
this.visibleLabels = clamp(numberOfLabels, MIN_LABELS, MAX_LABELS);
});
},
},
};
</script>
@@ -92,10 +92,10 @@ export default {
...mapGetters({ meta: 'notifications/getMeta' }),
},
mounted() {
this.$emitter.on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
bus.$on(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
destroyed() {
this.$emitter.off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
bus.$off(CMD_SNOOZE_NOTIFICATION, this.onCmdSnoozeNotification);
},
methods: {
openSnoozeNotificationModal() {
@@ -1,44 +0,0 @@
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
conversationId: {
type: Number,
default: 0,
},
},
computed: {
inboxIcon() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script>
<template>
<div
class="inline-flex items-center rounded-[4px] border border-slate-100 dark:border-slate-700/50 divide-x divide-slate-100 dark:divide-slate-700/50 bg-none"
>
<div v-if="inbox" class="flex items-center gap-0.5 py-0.5 px-1.5">
<fluent-icon
class="text-slate-600 dark:text-slate-300"
:icon="inboxIcon"
size="14"
/>
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ inbox.name }}
</span>
</div>
<div class="flex items-center py-0.5 px-1.5">
<span class="font-medium text-slate-600 dark:text-slate-200 text-xs">
{{ conversationId }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,10 @@
export const NOTIFICATION_TYPES_MAPPING = {
CONVERSATION_MENTION: ['alternate-email', 'text-woot-500 dark:text-woot-500'],
CONVERSATION_ASSIGNMENT: [
'keyboard-double-arrow-right',
'text-woot-500 dark:text-woot-500',
],
SLA_MISSED_FIRST_RESPONSE: ['heart-broken', 'text-red-500 dark:text-red-500'],
SLA_MISSED_NEXT_RESPONSE: ['heart-broken', 'text-red-500 dark:text-red-500'],
SLA_MISSED_RESOLUTION: ['heart-broken', 'text-red-500 dark:text-red-500'],
};
@@ -200,7 +200,7 @@ export default {
},
featureInboundEmailEnabled() {
return !!this.features?.inbound_emails;
return !!this.features.inbound_emails;
},
featureCustomReplyDomainEnabled() {
@@ -69,10 +69,8 @@
<script>
import { required, minLength, email } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
onClose: {
type: Function,
@@ -119,6 +117,9 @@ export default {
},
methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
async addAgent() {
try {
await this.$store.dispatch('agents/create', {
@@ -83,7 +83,6 @@ import WootSubmitButton from '../../../../components/buttons/FormSubmitButton.vu
import Modal from '../../../../components/Modal.vue';
import Auth from '../../../../api/auth';
import wootConstants from 'dashboard/constants/globals';
import alertMixin from 'shared/mixins/alertMixin';
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
@@ -92,7 +91,6 @@ export default {
WootSubmitButton,
Modal,
},
mixins: [alertMixin],
props: {
id: {
type: Number,
@@ -171,6 +169,9 @@ export default {
},
},
methods: {
showAlert(message) {
bus.$emit('newToastMessage', message);
},
async editAgent() {
try {
await this.$store.dispatch('agents/update', {
@@ -130,7 +130,6 @@ import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Thumbnail from '../../../../components/widgets/Thumbnail.vue';
import AddAgent from './AddAgent.vue';
import EditAgent from './EditAgent.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
@@ -138,7 +137,7 @@ export default {
EditAgent,
Thumbnail,
},
mixins: [globalConfigMixin, alertMixin],
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -231,19 +230,19 @@ export default {
async deleteAgent(id) {
try {
await this.$store.dispatch('agents/delete', id);
this.showAlertMessage(this.$t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
this.showAlert(this.$t('AGENT_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
this.showAlertMessage(this.$t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
this.showAlert(this.$t('AGENT_MGMT.DELETE.API.ERROR_MESSAGE'));
}
},
// Show SnackBar
showAlertMessage(message) {
showAlert(message) {
// Reset loading, current selected agent
this.loading[this.currentAgent.id] = false;
this.currentAgent = {};
// Show message
this.agentAPI.message = message;
this.showAlert(message);
bus.$emit('newToastMessage', message);
},
},
};
@@ -132,14 +132,12 @@
import { mapGetters } from 'vuex';
import AddCanned from './AddCanned.vue';
import EditCanned from './EditCanned.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
AddCanned,
EditCanned,
},
mixins: [alertMixin],
data() {
return {
loading: {},
@@ -189,13 +187,13 @@ export default {
});
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
},
showAlertMessage(message) {
showAlert(message) {
// Reset loading, current selected agent
this.loading[this.selectedResponse.id] = false;
this.selectedResponse = {};
// Show message
this.cannedResponseAPI.message = message;
this.showAlert(message);
bus.$emit('newToastMessage', message);
},
// Edit Function
openAddPopup() {
@@ -232,14 +230,12 @@ export default {
this.$store
.dispatch('deleteCannedResponse', id)
.then(() => {
this.showAlertMessage(
this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE')
);
this.showAlert(this.$t('CANNED_MGMT.DELETE.API.SUCCESS_MESSAGE'));
})
.catch(error => {
const errorMessage =
error?.message || this.$t('CANNED_MGMT.DELETE.API.ERROR_MESSAGE');
this.showAlertMessage(errorMessage);
this.showAlert(errorMessage);
});
},
},
@@ -51,13 +51,11 @@ import { mapGetters } from 'vuex';
import InboxMembersAPI from '../../../../api/inboxMembers';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
PageHeader,
},
mixins: [alertMixin],
validations: {
selectedAgents: {
@@ -100,7 +98,7 @@ export default {
},
});
} catch (error) {
this.showAlert(error.message);
bus.$emit('newToastMessage', error.message);
}
this.isCreating = false;
},
@@ -136,13 +136,12 @@ import Settings from './Settings.vue';
import adminMixin from '../../../../mixins/isAdmin';
import accountMixin from '../../../../mixins/account';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
Settings,
},
mixins: [adminMixin, accountMixin, globalConfigMixin, alertMixin],
mixins: [adminMixin, accountMixin, globalConfigMixin],
data() {
return {
loading: {},
@@ -195,9 +194,15 @@ export default {
async deleteInbox({ id }) {
try {
await this.$store.dispatch('inboxes/delete', id);
this.showAlert(this.$t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE'));
bus.$emit(
'newToastMessage',
this.$t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE')
);
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE'));
bus.$emit(
'newToastMessage',
this.$t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE')
);
}
},
@@ -21,8 +21,10 @@
</woot-tabs>
</setting-intro-banner>
<microsoft-reauthorize v-if="microsoftUnauthorized" :inbox="inbox" />
<facebook-reauthorize v-if="facebookUnauthorized" :inbox="inbox" />
<inbox-reconnection-required
v-if="isReconnectionRequired"
class="mx-8 mt-5"
/>
<div v-if="selectedTabKey === 'inbox_settings'" class="mx-8">
<settings-section
@@ -400,6 +402,7 @@
@click="updateInbox"
/>
</settings-section>
<facebook-reauthorize v-if="isAFacebookInbox" :inbox-id="inbox.id" />
</div>
<div v-if="selectedTabKey === 'collaborators'" class="mx-8">
@@ -437,7 +440,7 @@ import WeeklyAvailability from './components/WeeklyAvailability.vue';
import GreetingsEditor from 'shared/components/GreetingsEditor.vue';
import ConfigurationPage from './settingsPage/ConfigurationPage.vue';
import CollaboratorsPage from './settingsPage/CollaboratorsPage.vue';
import MicrosoftReauthorize from './channels/microsoft/Reauthorize.vue';
import InboxReconnectionRequired from './components/InboxReconnectionRequired';
import WidgetBuilder from './WidgetBuilder.vue';
import BotConfiguration from './components/BotConfiguration.vue';
import { FEATURE_FLAGS } from '../../../../featureFlags';
@@ -456,7 +459,7 @@ export default {
WeeklyAvailability,
WidgetBuilder,
SenderNameExamplePreview,
MicrosoftReauthorize,
InboxReconnectionRequired,
},
mixins: [alertMixin, configMixin, inboxMixin],
data() {
@@ -618,11 +621,8 @@ export default {
return true;
return false;
},
microsoftUnauthorized() {
return this.isAMicrosoftInbox && this.inbox.reauthorization_required;
},
facebookUnauthorized() {
return this.isAFacebookInbox && this.inbox.reauthorization_required;
isReconnectionRequired() {
return false;
},
},
watch: {
@@ -18,8 +18,8 @@
/>
<woot-submit-button
icon="brand-twitter"
button-text="Sign in with Microsoft"
type="submit"
:button-text="$t('INBOX_MGMT.ADD.MICROSOFT.SIGN_IN')"
:loading="isRequestingAuthorization"
/>
</form>
@@ -1,44 +1,63 @@
<script setup>
import { ref } from 'vue';
import InboxReconnectionRequired from '../../components/InboxReconnectionRequired';
import microsoftClient from 'dashboard/api/channel/microsoftClient';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
const { t } = useI18n();
const props = defineProps({
inbox: {
type: Object,
default: () => ({}),
},
});
const isRequestingAuthorization = ref(false);
async function requestAuthorization() {
try {
isRequestingAuthorization.value = true;
const response = await microsoftClient.generateAuthorization({
email: props.inbox.email,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE'));
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<inbox-reconnection-required
class="mx-8 mt-5"
@reauthorize="requestAuthorization"
/>
<div class="mx-8">
<settings-section
:title="$t('INBOX_MGMT.MICROSOFT.TITLE')"
:sub-title="$t('INBOX_MGMT.MICROSOFT.SUBTITLE')"
>
<div class="mb-6">
<form @submit.prevent="requestAuthorization">
<woot-submit-button
icon="brand-twitter"
button-text="Sign in with Microsoft"
type="submit"
:loading="isRequestingAuthorization"
/>
</form>
</div>
</settings-section>
</div>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import microsoftClient from '../../../../../../api/channel/microsoftClient';
import SettingsSection from '../../../../../../components/SettingsSection.vue';
export default {
components: {
SettingsSection,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
data() {
return { isRequestingAuthorization: false };
},
methods: {
async requestAuthorization() {
try {
this.isRequestingAuthorization = true;
const response = await microsoftClient.generateAuthorization({
email: this.inbox.email,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
this.showAlert(this.$t('INBOX_MGMT.ADD.MICROSOFT.ERROR_MESSAGE'));
} finally {
this.isRequestingAuthorization = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.smtp-details-wrap {
margin-bottom: var(--space-medium);
}
</style>
@@ -1,14 +1,19 @@
<script setup>
import Banner from 'dashboard/components/ui/Banner.vue';
defineProps({
actionUrl: {
type: String,
required: true,
},
});
</script>
<template>
<banner
color-scheme="alert"
class="justify-start rounded-md"
:banner-message="$t('INBOX_MGMT.RECONNECTION_REQUIRED')"
:action-button-label="$t('INBOX_MGMT.CLICK_TO_RECONNECT')"
has-action-button
@click="$emit('reauthorize')"
/>
<div
class="flex items-center gap-2 px-4 py-3 text-sm text-white bg-red-500 rounded-md dark:bg-red-800/30 dark:text-red-50 min-h-10"
>
<fluent-icon icon="error-circle" class="text-white dark:text-red-50" />
<slot>
<span v-html="$t('INBOX_MGMT.RECONNECTION_REQUIRED', { actionUrl })" />
</slot>
</div>
</template>
@@ -1,32 +1,38 @@
<template>
<inbox-reconnection-required class="mx-8 mt-5" @reauthorize="tryFBlogin" />
<settings-section
:title="$t('INBOX_MGMT.FACEBOOK_REAUTHORIZE.TITLE')"
:sub-title="$t('INBOX_MGMT.FACEBOOK_REAUTHORIZE.SUBTITLE')"
>
<a class="fb--login" href="#" @click="tryFBlogin">
<img
src="~dashboard/assets/images/channels/facebook_login.png"
alt="Facebook-logo"
/>
</a>
</settings-section>
</template>
<script>
/* global FB */
import InboxReconnectionRequired from '../components/InboxReconnectionRequired';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: {
InboxReconnectionRequired,
SettingsSection,
},
mixins: [alertMixin],
props: {
inbox: {
type: Object,
inboxId: {
type: Number,
required: true,
},
},
computed: {
inboxId() {
return this.inbox.id;
},
},
mounted() {
this.initFB();
this.loadFBsdk();
},
methods: {
initFB() {
if (window.fbSDKLoaded === undefined) {
@@ -104,6 +104,10 @@
</div>
<imap-settings :inbox="inbox" />
<smtp-settings v-if="inbox.imap_enabled" :inbox="inbox" />
<microsoft-reauthorize
v-if="inbox.microsoft_reauthorization"
:inbox="inbox"
/>
</div>
<div v-else-if="isAWhatsAppChannel && !isATwilioChannel">
<div v-if="inbox.provider_config" class="mx-8">
@@ -126,7 +130,7 @@
"
>
<div
class="flex items-center justify-between flex-1 mt-2 whatsapp-settings--content"
class="whatsapp-settings--content items-center flex flex-1 justify-between mt-2"
>
<woot-input
v-model.trim="whatsAppInboxAPIKey"
@@ -156,6 +160,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue';
import MicrosoftReauthorize from '../channels/microsoft/Reauthorize.vue';
import { required } from 'vuelidate/lib/validators';
export default {
@@ -163,6 +168,7 @@ export default {
SettingsSection,
ImapSettings,
SmtpSettings,
MicrosoftReauthorize,
},
mixins: [inboxMixin, alertMixin],
props: {
@@ -128,7 +128,7 @@ export default {
this.$store.dispatch('agents/get');
this.fetchAllData();
this.$emitter.on('fetch_overview_reports', () => {
bus.$on('fetch_overview_reports', () => {
this.fetchAllData();
});
},
@@ -64,19 +64,17 @@ export default {
},
computed: {
tableData() {
return this.agentMetrics
.filter(agentMetric => this.getAgentInformation(agentMetric.id))
.map(agent => {
const agentInformation = this.getAgentInformation(agent.id);
return {
agent: agentInformation.name || agentInformation.available_name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open: agent.metric.open || 0,
unattended: agent.metric.unattended || 0,
status: agentInformation.availability_status,
};
});
return this.agentMetrics.map(agent => {
const agentInformation = this.getAgentInformation(agent.id);
return {
agent: agentInformation.name,
email: agentInformation.email,
thumbnail: agentInformation.thumbnail,
open: agent.metric.open || 0,
unattended: agent.metric.unattended || 0,
status: agentInformation.availability_status,
};
});
},
columns() {
return [
@@ -132,7 +130,7 @@ export default {
this.$emit('page-change', pageIndex);
},
getAgentInformation(id) {
return this.agents?.find(agent => agent.id === Number(id));
return this.agents.find(agent => agent.id === Number(id));
},
},
};
@@ -109,10 +109,6 @@ export const actions = {
// silent error
}
},
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
};
export const mutations = {
@@ -37,10 +37,9 @@ const actions = {
}
},
fetchAllConversations: async ({ commit, state, dispatch }) => {
fetchAllConversations: async ({ commit, dispatch }, params) => {
commit(types.SET_LIST_LOADING_STATUS);
try {
const params = state.conversationFilters;
const {
data: { data },
} = await ConversationApi.get(params);
@@ -447,14 +446,6 @@ const actions = {
commit(types.CLEAR_CONVERSATION_FILTERS);
},
setChatListFilters({ commit }, data) {
commit(types.SET_CHAT_LIST_FILTERS, data);
},
updateChatListFilters({ commit }, data) {
commit(types.UPDATE_CHAT_LIST_FILTERS, data);
},
assignPriority: async ({ dispatch }, { conversationId, priority }) => {
try {
await ConversationApi.togglePriority({

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