mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
Feature: Availability Statuses (#874)
Co-authored-by: Pranav Raj S <pranav@thoughtwoot.com>
This commit is contained in:
@@ -118,21 +118,18 @@ export default {
|
||||
return axios.post(urlData.url, { email });
|
||||
},
|
||||
|
||||
profileUpdate({ name, email, password, password_confirmation, avatar }) {
|
||||
profileUpdate({ password, password_confirmation, ...profileAttributes }) {
|
||||
const formData = new FormData();
|
||||
if (name) {
|
||||
formData.append('profile[name]', name);
|
||||
}
|
||||
if (email) {
|
||||
formData.append('profile[email]', email);
|
||||
}
|
||||
Object.keys(profileAttributes).forEach(key => {
|
||||
const value = profileAttributes[key];
|
||||
if (value) {
|
||||
formData.append(`profile[${key}]`, value);
|
||||
}
|
||||
});
|
||||
if (password && password_confirmation) {
|
||||
formData.append('profile[password]', password);
|
||||
formData.append('profile[password_confirmation]', password_confirmation);
|
||||
}
|
||||
if (avatar) {
|
||||
formData.append('profile[avatar]', avatar);
|
||||
}
|
||||
return axios.put(endPoints('profileUpdate').url, formData);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -56,7 +56,11 @@
|
||||
</div>
|
||||
</transition>
|
||||
<div class="current-user" @click.prevent="showOptions()">
|
||||
<thumbnail :src="currentUser.avatar_url" :username="currentUser.name" />
|
||||
<thumbnail
|
||||
:src="currentUser.avatar_url"
|
||||
:username="currentUser.name"
|
||||
:status="currentUser.availability_status"
|
||||
/>
|
||||
<div class="current-user--data">
|
||||
<h3 class="current-user--name">
|
||||
{{ currentUser.name }}
|
||||
|
||||
@@ -21,11 +21,6 @@
|
||||
:style="badgeStyle"
|
||||
src="~dashboard/assets/images/fb-badge.png"
|
||||
/>
|
||||
<div
|
||||
v-else-if="status === 'online'"
|
||||
class="source-badge user--online"
|
||||
:style="statusStyle"
|
||||
></div>
|
||||
<img
|
||||
v-if="badge === 'Channel::TwitterProfile'"
|
||||
id="badge"
|
||||
@@ -33,7 +28,6 @@
|
||||
:style="badgeStyle"
|
||||
src="~dashboard/assets/images/twitter-badge.png"
|
||||
/>
|
||||
|
||||
<img
|
||||
v-if="badge === 'Channel::TwilioSms'"
|
||||
id="badge"
|
||||
@@ -41,6 +35,11 @@
|
||||
:style="badgeStyle"
|
||||
src="~dashboard/assets/images/channels/whatsapp.png"
|
||||
/>
|
||||
<div
|
||||
v-if="showStatusIndicator"
|
||||
:class="`source-badge user-online-status user-online-status--${status}`"
|
||||
:style="statusStyle"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
@@ -89,6 +88,9 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showStatusIndicator() {
|
||||
return this.status === 'online' || this.status === 'busy';
|
||||
},
|
||||
avatarSize() {
|
||||
return Number(this.size.replace(/\D+/g, ''));
|
||||
},
|
||||
@@ -150,8 +152,7 @@ export default {
|
||||
width: $space-slab;
|
||||
}
|
||||
|
||||
.user--online {
|
||||
background: $success-color;
|
||||
.user-online-status {
|
||||
border-radius: 50%;
|
||||
bottom: $space-micro;
|
||||
|
||||
@@ -159,5 +160,13 @@ export default {
|
||||
content: ' ';
|
||||
}
|
||||
}
|
||||
|
||||
.user-online-status--online {
|
||||
background: $success-color;
|
||||
}
|
||||
|
||||
.user-online-status--busy {
|
||||
background: $warning-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
:badge="currentContact.channel"
|
||||
class="columns"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
size="40px"
|
||||
/>
|
||||
<div class="conversation--details columns">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
size="40px"
|
||||
:badge="currentContact.channel"
|
||||
:username="currentContact.name"
|
||||
:status="currentContact.availability_status"
|
||||
/>
|
||||
<div class="user--profile__meta">
|
||||
<h3 v-if="!isContactPanelOpen" class="user--name text-truncate">
|
||||
|
||||
@@ -18,6 +18,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.typing_on': this.onTypingOn,
|
||||
'conversation.typing_off': this.onTypingOff,
|
||||
'conversation.contact_changed': this.onConversationContactChange,
|
||||
'presence.update': this.onPresenceUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +30,11 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('updateMessage', data);
|
||||
};
|
||||
|
||||
onPresenceUpdate = data => {
|
||||
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
|
||||
this.app.$store.dispatch('agents/updatePresence', data.users);
|
||||
};
|
||||
|
||||
onConversationContactChange = payload => {
|
||||
const { meta = {}, id: conversationId } = payload;
|
||||
const { sender } = meta || {};
|
||||
|
||||
@@ -48,6 +48,23 @@
|
||||
"ERROR": "Please enter a valid name",
|
||||
"PLACEHOLDER": "Please enter your name, this would be displayed in conversations"
|
||||
},
|
||||
"AVAILABILITY": {
|
||||
"LABEL": "Availability",
|
||||
"STATUSES_LIST": [
|
||||
{
|
||||
"value": "online",
|
||||
"label": "Online"
|
||||
},
|
||||
{
|
||||
"value": "busy",
|
||||
"label": "Busy"
|
||||
},
|
||||
{
|
||||
"value": "offline",
|
||||
"label": "Offline"
|
||||
}
|
||||
]
|
||||
},
|
||||
"EMAIL": {
|
||||
"LABEL": "Your email address",
|
||||
"ERROR": "Please enter a valid email address",
|
||||
|
||||
@@ -38,6 +38,19 @@
|
||||
{{ $t('PROFILE_SETTINGS.FORM.EMAIL.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
{{ $t('PROFILE_SETTINGS.FORM.AVAILABILITY.LABEL') }}
|
||||
<select v-model="availability">
|
||||
<option
|
||||
v-for="status in availabilityStatuses"
|
||||
:key="status.key"
|
||||
class="text-capitalize"
|
||||
:value="status.value"
|
||||
>
|
||||
{{ status.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile--settings--row row">
|
||||
@@ -99,16 +112,17 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/* global bus */
|
||||
import { required, minLength, email } from 'vuelidate/lib/validators';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { clearCookiesOnLogout } from '../../../../store/utils/api';
|
||||
import NotificationSettings from './NotificationSettings';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NotificationSettings,
|
||||
},
|
||||
mixin: [alertMixin],
|
||||
data() {
|
||||
return {
|
||||
avatarFile: '',
|
||||
@@ -117,7 +131,11 @@ export default {
|
||||
email: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
availability: 'online',
|
||||
isUpdating: false,
|
||||
availabilityStatuses: this.$t(
|
||||
'PROFILE_SETTINGS.FORM.AVAILABILITY.STATUSES_LIST'
|
||||
),
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
@@ -164,11 +182,12 @@ export default {
|
||||
this.name = this.currentUser.name;
|
||||
this.email = this.currentUser.email;
|
||||
this.avatarUrl = this.currentUser.avatar_url;
|
||||
this.availability = this.currentUser.availability_status;
|
||||
},
|
||||
async updateUser() {
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
bus.$emit('newToastMessage', this.$t('PROFILE_SETTINGS.FORM.ERROR'));
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
|
||||
return;
|
||||
}
|
||||
this.isUpdating = true;
|
||||
@@ -179,15 +198,13 @@ export default {
|
||||
email: this.email,
|
||||
avatar: this.avatarFile,
|
||||
password: this.password,
|
||||
availability: this.availability,
|
||||
password_confirmation: this.passwordConfirmation,
|
||||
});
|
||||
this.isUpdating = false;
|
||||
if (hasEmailChanged) {
|
||||
clearCookiesOnLogout();
|
||||
bus.$emit(
|
||||
'newToastMessage',
|
||||
this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED')
|
||||
);
|
||||
this.showAlert(this.$t('PROFILE_SETTINGS.AFTER_EMAIL_CHANGED'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.isUpdating = false;
|
||||
|
||||
@@ -57,6 +57,13 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
|
||||
updatePresence: async ({ commit }, data) => {
|
||||
commit(types.default.SET_AGENT_UPDATING_STATUS, true);
|
||||
commit(types.default.UPDATE_AGENTS_PRESENCE, data);
|
||||
commit(types.default.SET_AGENT_UPDATING_STATUS, false);
|
||||
},
|
||||
|
||||
delete: async ({ commit }, agentId) => {
|
||||
commit(types.default.SET_AGENT_DELETING_STATUS, true);
|
||||
try {
|
||||
@@ -88,6 +95,7 @@ export const mutations = {
|
||||
[types.default.ADD_AGENT]: MutationHelpers.create,
|
||||
[types.default.EDIT_AGENT]: MutationHelpers.update,
|
||||
[types.default.DELETE_AGENT]: MutationHelpers.destroy,
|
||||
[types.default.UPDATE_AGENTS_PRESENCE]: MutationHelpers.updatePresence,
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -59,6 +59,10 @@ export const actions = {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
|
||||
updatePresence: ({ commit }, data) => {
|
||||
commit(types.default.UPDATE_CONTACTS_PRESENCE, data);
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
@@ -88,6 +92,21 @@ export const mutations = {
|
||||
[types.default.EDIT_CONTACT]: ($state, data) => {
|
||||
Vue.set($state.records, data.id, data);
|
||||
},
|
||||
|
||||
[types.default.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
|
||||
Object.values($state.records).forEach(element => {
|
||||
const availabilityStatus = data[element.id];
|
||||
if (availabilityStatus) {
|
||||
Vue.set(
|
||||
$state.records[element.id],
|
||||
'availability_status',
|
||||
availabilityStatus
|
||||
);
|
||||
} else {
|
||||
Vue.delete($state.records[element.id], 'availability_status');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -93,4 +93,16 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updatePresence', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
const data = { users: { 1: 'online' }, contacts: { 2: 'online' } };
|
||||
actions.updatePresence({ commit }, data);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.default.SET_AGENT_UPDATING_STATUS, true],
|
||||
[types.default.UPDATE_AGENTS_PRESENCE, data],
|
||||
[types.default.SET_AGENT_UPDATING_STATUS, false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,4 +60,40 @@ describe('#mutations', () => {
|
||||
expect(state.records).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#UPDATE_AGENTS_PRESENCE', () => {
|
||||
it('updates agent presence', () => {
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Agent1',
|
||||
email: 'agent1@chatwoot.com',
|
||||
availability_status: 'offline',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Agent1',
|
||||
email: 'agent1@chatwoot.com',
|
||||
availability_status: 'online',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mutations[types.default.UPDATE_AGENTS_PRESENCE](state, { '1': 'busy' });
|
||||
expect(state.records).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
name: 'Agent1',
|
||||
email: 'agent1@chatwoot.com',
|
||||
availability_status: 'busy',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Agent1',
|
||||
email: 'agent1@chatwoot.com',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ export default {
|
||||
ADD_AGENT: 'ADD_AGENT',
|
||||
EDIT_AGENT: 'EDIT_AGENT',
|
||||
DELETE_AGENT: 'DELETE_AGENT',
|
||||
UPDATE_AGENTS_PRESENCE: 'UPDATE_AGENTS_PRESENCE',
|
||||
|
||||
// Canned Response
|
||||
SET_CANNED_UI_FLAG: 'SET_CANNED_UI_FLAG',
|
||||
@@ -91,6 +92,7 @@ export default {
|
||||
SET_CONTACT_ITEM: 'SET_CONTACT_ITEM',
|
||||
SET_CONTACTS: 'SET_CONTACTS',
|
||||
EDIT_CONTACT: 'EDIT_CONTACT',
|
||||
UPDATE_CONTACTS_PRESENCE: 'UPDATE_CONTACTS_PRESENCE',
|
||||
|
||||
// Contact Conversation
|
||||
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { createConsumer } from '@rails/actioncable';
|
||||
|
||||
const PRESENCE_INTERVAL = 60000;
|
||||
|
||||
class BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
this.consumer = createConsumer();
|
||||
this.consumer.subscriptions.create(
|
||||
this.subscription = this.consumer.subscriptions.create(
|
||||
{
|
||||
channel: 'RoomChannel',
|
||||
pubsub_token: pubsubToken,
|
||||
account_id: app.$store.getters.getCurrentAccountId,
|
||||
user_id: app.$store.getters.getCurrentUserID,
|
||||
},
|
||||
{
|
||||
updatePresence() {
|
||||
this.perform('update_presence');
|
||||
},
|
||||
received: this.onReceived,
|
||||
}
|
||||
);
|
||||
this.app = app;
|
||||
this.events = {};
|
||||
this.isAValidEvent = () => true;
|
||||
|
||||
setInterval(() => {
|
||||
this.subscription.updatePresence();
|
||||
}, PRESENCE_INTERVAL);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
||||
@@ -34,6 +34,17 @@ export const updateAttributes = (state, data) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const updatePresence = (state, data) => {
|
||||
state.records.forEach((element, index) => {
|
||||
const availabilityStatus = data[element.id];
|
||||
if (availabilityStatus) {
|
||||
Vue.set(state.records[index], 'availability_status', availabilityStatus);
|
||||
} else {
|
||||
Vue.delete(state.records[index], 'availability_status');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const destroy = (state, id) => {
|
||||
state.records = state.records.filter(record => record.id !== id);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.typing_off': this.onTypingOff,
|
||||
'conversation.resolved': this.onStatusChange,
|
||||
'conversation.opened': this.onStatusChange,
|
||||
'presence.update': this.onPresenceUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +26,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
this.app.$store.dispatch('conversation/updateMessage', data);
|
||||
};
|
||||
|
||||
onPresenceUpdate = data => {
|
||||
this.app.$store.dispatch('agent/updatePresence', data.users);
|
||||
};
|
||||
|
||||
onTypingOn = () => {
|
||||
this.clearTimer();
|
||||
this.app.$store.dispatch('conversation/toggleAgentTyping', {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Vue from 'vue';
|
||||
import { getAvailableAgents } from 'widget/api/agent';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
@@ -27,12 +28,16 @@ export const actions = {
|
||||
commit('setHasFetched', true);
|
||||
}
|
||||
},
|
||||
updatePresence: async ({ commit }, data) => {
|
||||
commit('updatePresence', data);
|
||||
},
|
||||
};
|
||||
|
||||
export const mutations = {
|
||||
setAgents($state, data) {
|
||||
Vue.set($state, 'records', data);
|
||||
},
|
||||
updatePresence: MutationHelpers.updatePresence,
|
||||
setError($state, value) {
|
||||
Vue.set($state.uiFlags, 'isError', value);
|
||||
},
|
||||
|
||||
@@ -25,4 +25,9 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updatePresence', () => {
|
||||
actions.updatePresence({ commit }, { 1: 'online' });
|
||||
expect(commit.mock.calls).toEqual([['updatePresence', { 1: 'online' }]]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mutations } from '../../agent';
|
||||
import agents from './data';
|
||||
import { agents } from './data';
|
||||
|
||||
describe('#mutations', () => {
|
||||
describe('#setAgents', () => {
|
||||
@@ -25,4 +25,35 @@ describe('#mutations', () => {
|
||||
expect(state.uiFlags.hasFetched).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updatePresence', () => {
|
||||
it('updates agent presence', () => {
|
||||
const state = { records: agents };
|
||||
mutations.updatePresence(state, { 1: 'busy', 2: 'online' });
|
||||
expect(state.records).toEqual([
|
||||
{
|
||||
id: 1,
|
||||
name: 'John',
|
||||
avatar_url: '',
|
||||
availability_status: 'busy',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Xavier',
|
||||
avatar_url: '',
|
||||
availability_status: 'online',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Pranav',
|
||||
avatar_url: '',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Nithin',
|
||||
avatar_url: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user