feat: Display "Snoozed Until" time on conversation header (#3028)

This commit is contained in:
Pranav Raj S 2021-09-29 19:33:51 +05:30 committed by GitHub
parent 49ac4a4400
commit 57abdc4d5f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 217 additions and 172 deletions

View file

@ -14,6 +14,7 @@
@import 'helper-classes';
@import 'formulate';
@import 'date-picker';
@import 'utility-helpers';
@import 'foundation-sites/scss/foundation';
@import '~bourbon/core/bourbon';

View file

@ -42,14 +42,6 @@ $resolve-button-width: 13.2rem;
margin-right: var(--space-normal);
min-width: 0;
.user--name {
@include margin(0);
display: inline-block;
font-size: $font-size-medium;
line-height: 1.3;
text-transform: capitalize;
width: 100%;
}
.user--profile__meta {
align-items: flex-start;
@ -59,12 +51,6 @@ $resolve-button-width: 13.2rem;
margin-left: $space-slab;
min-width: 0;
}
.user--profile__button {
font-size: $font-size-mini;
margin-top: $space-micro;
padding: 0;
}
}
}

View file

@ -0,0 +1,35 @@
<template>
<span class="inbox--name">
<i :class="computedInboxClass" />
{{ inbox.name }}
</span>
</template>
<script>
import { getInboxClassByType } from 'dashboard/helper/inbox';
export default {
props: {
inbox: {
type: Object,
default: () => {},
},
},
computed: {
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
},
};
</script>
<style scoped>
.inbox--name {
padding: var(--space-micro) 0;
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
font-size: var(--font-size-mini);
}
</style>

View file

@ -19,11 +19,7 @@
/>
<div class="conversation--details columns">
<div class="conversation--metadata">
<span v-if="showInboxName" class="label">
<i :class="computedInboxClass" />
{{ inboxName }}
</span>
<inbox-name v-if="showInboxName" :inbox="inbox" />
<span
v-if="showAssignee && assignee"
class="label assignee-label text-truncate"
@ -72,16 +68,17 @@
import { mapGetters } from 'vuex';
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { getInboxClassByType } from 'dashboard/helper/inbox';
import Thumbnail from '../Thumbnail';
import conversationMixin from '../../../mixins/conversations';
import timeMixin from '../../../mixins/time';
import router from '../../../routes';
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
import InboxName from '../InboxName';
import inboxMixin from 'shared/mixins/inboxMixin';
export default {
components: {
InboxName,
Thumbnail,
},
@ -192,12 +189,6 @@ export default {
return stateInbox;
},
computedInboxClass() {
const { phone_number: phoneNumber, channel_type: type } = this.inbox;
const classByType = getInboxClassByType(type, phoneNumber);
return classByType;
},
showInboxName() {
return (
!this.hideInboxName &&
@ -244,15 +235,6 @@ export default {
}
}
.conversation--details .label {
padding: var(--space-micro) 0 var(--space-micro) 0;
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
font-size: var(--font-size-mini);
}
.conversation--details {
.conversation--user {
padding-top: var(--space-micro);
@ -276,6 +258,15 @@ export default {
justify-content: space-between;
padding-right: var(--space-normal);
.label {
padding: var(--space-micro) 0 var(--space-micro) 0;
line-height: var(--space-slab);
font-weight: var(--font-weight-medium);
background: none;
color: var(--s-500);
font-size: var(--font-size-mini);
}
.assignee-label {
max-width: 50%;
}

View file

@ -12,22 +12,25 @@
<h3 class="user--name text-truncate">
{{ currentContact.name }}
</h3>
<div class="conversation--header--actions">
<inbox-name :inbox="inbox" class="margin-right-small" />
<span
v-if="isSnoozed"
class="snoozed--display-text margin-right-small"
>
{{ snoozedDisplayText }}
</span>
<woot-button
class="user--profile__button"
class="user--profile__button margin-right-small"
size="small"
variant="link"
@click="$emit('contact-panel-toggle')"
>
{{
`${
isContactPanelOpen
? $t('CONVERSATION.HEADER.CLOSE')
: $t('CONVERSATION.HEADER.OPEN')
} ${$t('CONVERSATION.HEADER.DETAILS')}`
}}
{{ contactPanelToggleText }}
</woot-button>
</div>
</div>
</div>
<div
class="header-actions-wrap"
:class="{ 'has-open-sidebar': isContactPanelOpen }"
@ -44,9 +47,13 @@ import agentMixin from '../../../mixins/agentMixin.js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import inboxMixin from 'shared/mixins/inboxMixin';
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
import wootConstants from '../../../constants';
import differenceInHours from 'date-fns/differenceInHours';
import InboxName from '../InboxName';
export default {
components: {
InboxName,
MoreActions,
Thumbnail,
},
@ -61,39 +68,50 @@ export default {
default: false,
},
},
data() {
return {
currentChatAssignee: null,
inboxId: null,
};
},
computed: {
...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat',
}),
chatMetadata() {
return this.chat.meta;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
return stateInbox;
},
currentContact() {
return this.$store.getters['contacts/getContact'](
this.chat.meta.sender.id
);
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
mounted() {
snoozedDisplayText() {
const { snoozed_until: snoozedUntil } = this.currentChat;
if (snoozedUntil) {
// When the snooze is applied, it schedules the unsnooze event to next day/week 9AM.
// By that logic if the time difference is less than or equal to 24 + 9 hours we can consider it tomorrow.
const MAX_TIME_DIFFERENCE = 33;
const isSnoozedUntilTomorrow =
differenceInHours(new Date(snoozedUntil), new Date()) <=
MAX_TIME_DIFFERENCE;
return this.$t(
isSnoozedUntilTomorrow
? 'CONVERSATION.HEADER.SNOOZED_UNTIL_TOMORROW'
: 'CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_WEEK'
);
}
return this.$t('CONVERSATION.HEADER.SNOOZED_UNTIL_NEXT_REPLY');
},
contactPanelToggleText() {
return `${
this.isContactPanelOpen
? this.$t('CONVERSATION.HEADER.CLOSE')
: this.$t('CONVERSATION.HEADER.OPEN')
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
},
inbox() {
const { inbox_id: inboxId } = this.chat;
this.inboxId = inboxId;
return this.$store.getters['inboxes/getInbox'](inboxId);
},
},
methods: {
@ -129,4 +147,28 @@ export default {
flex-shrink: 0;
}
}
.user--name {
display: inline-block;
font-size: var(--font-size-medium);
line-height: 1.3;
margin: 0;
text-transform: capitalize;
width: 100%;
}
.conversation--header--actions {
align-items: center;
display: flex;
font-size: var(--font-size-mini);
.user--profile__button {
padding: 0;
}
.snoozed--display-text {
font-weight: var(--font-weight-medium);
color: var(--y-900);
}
}
</style>

View file

@ -1,41 +1,29 @@
<template>
<div class="flex-container actions--container">
<woot-button
v-if="!currentChat.muted"
v-tooltip="$t('CONTACT_PANEL.MUTE_CONTACT')"
class="hollow secondary actions--button"
icon="ion-volume-mute"
@click="mute"
/>
<woot-button
v-else
v-tooltip.left="$t('CONTACT_PANEL.UNMUTE_CONTACT')"
class="hollow secondary actions--button"
icon="ion-volume-medium"
@click="unmute"
/>
<woot-button
v-tooltip="$t('CONTACT_PANEL.SEND_TRANSCRIPT')"
class="hollow secondary actions--button"
icon="ion-share"
@click="toggleEmailActionsModal"
/>
<resolve-action
:conversation-id="currentChat.id"
:status="currentChat.status"
/>
<woot-button
class="more--button"
variant="clear"
size="large"
color-scheme="secondary"
icon="ion-android-more-vertical"
@click="toggleConversationActions"
/>
<div
v-if="showConversationActions"
v-on-clickaway="hideConversationActions"
class="dropdown-pane dropdowm--bottom"
:class="{ 'dropdown-pane--open': showConversationActions }"
>
<woot-dropdown-menu>
<woot-dropdown-item v-if="!currentChat.muted">
<button class="button clear alert " @click="mute">
<span>{{ $t('CONTACT_PANEL.MUTE_CONTACT') }}</span>
</button>
</woot-dropdown-item>
<woot-dropdown-item v-else>
<button class="button clear alert" @click="unmute">
<span>{{ $t('CONTACT_PANEL.UNMUTE_CONTACT') }}</span>
</button>
</woot-dropdown-item>
<woot-dropdown-item>
<button class="button clear" @click="toggleEmailActionsModal">
{{ $t('CONTACT_PANEL.SEND_TRANSCRIPT') }}
</button>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<email-transcript-modal
v-if="showEmailActionsModal"
:show="showEmailActionsModal"
@ -50,13 +38,9 @@ import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import EmailTranscriptModal from './EmailTranscriptModal';
import ResolveAction from '../../buttons/ResolveAction';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
export default {
components: {
WootDropdownMenu,
WootDropdownItem,
EmailTranscriptModal,
ResolveAction,
},
@ -97,7 +81,16 @@ export default {
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/mixins';
.actions--container {
align-items: center;
.button {
font-size: var(--font-size-large);
margin-right: var(--space-small);
border-color: var(--color-border);
color: var(--s-400);
}
}
.more--button {
align-items: center;

View file

@ -1,6 +1,7 @@
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';
@ -10,6 +11,7 @@ import MoreActions from '../MoreActions';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueI18n);
localVue.use(VTooltip);
localVue.component('woot-button', Button);
@ -63,21 +65,9 @@ describe('MoveActions', () => {
moreActions = mount(MoreActions, { store, localVue, i18n: i18nConfig });
});
it('opens the menu when user clicks "more"', async () => {
expect(moreActions.find('.dropdown-pane').exists()).toBe(false);
await moreActions.find('.more--button').trigger('click');
expect(moreActions.find('.dropdown-pane').exists()).toBe(true);
});
describe('muting discussion', () => {
it('triggers "muteConversation"', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(muteConversation).toBeCalledWith(
expect.any(Object),
@ -87,11 +77,7 @@ describe('MoveActions', () => {
});
it('shows alert', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',
@ -106,11 +92,7 @@ describe('MoveActions', () => {
});
it('triggers "unmuteConversation"', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(unmuteConversation).toBeCalledWith(
expect.any(Object),
@ -120,11 +102,7 @@ describe('MoveActions', () => {
});
it('shows alert', async () => {
await moreActions.find('.more--button').trigger('click');
await moreActions
.find('.dropdown-pane button:first-child')
.trigger('click');
await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith(
'newToastMessage',

View file

@ -39,7 +39,10 @@
"OPEN_ACTION": "Open",
"OPEN": "More",
"CLOSE": "Close",
"DETAILS": "details"
"DETAILS": "details",
"SNOOZED_UNTIL_TOMORROW": "Snoozed until tomorrow",
"SNOOZED_UNTIL_NEXT_WEEK": "Snoozed until next week",
"SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending",

View file

@ -91,7 +91,6 @@ export default {
</script>
<style lang="scss" scoped>
@import '~dashboard/assets/scss/_utility-helpers.scss';
.page-title {
margin: 0;
}

View file

@ -24,8 +24,9 @@ const actions = {
commit(types.default.SET_LIST_LOADING_STATUS);
try {
const response = await ConversationApi.get(params);
const { data } = response.data;
const { payload: chatList, meta: metaData } = data;
const {
data: { payload: chatList, meta: metaData },
} = response.data;
commit(types.default.SET_ALL_CONVERSATION, chatList);
dispatch('conversationStats/set', metaData);
dispatch('conversationLabels/setBulkConversationLabels', chatList);
@ -36,10 +37,7 @@ const actions = {
);
dispatch(
'conversationPage/setCurrentPage',
{
filter: params.assigneeType,
page: params.page,
},
{ filter: params.assigneeType, page: params.page },
{ root: true }
);
if (!chatList.length) {
@ -69,10 +67,7 @@ const actions = {
} = await MessageApi.getPreviousMessages(data);
commit(
`conversationMetadata/${types.default.SET_CONVERSATION_METADATA}`,
{
id: data.conversationId,
data: meta,
}
{ id: data.conversationId, data: meta }
);
commit(types.default.SET_PREVIOUS_CONVERSATIONS, {
id: data.conversationId,
@ -140,14 +135,22 @@ const actions = {
{ conversationId, status, snoozedUntil = null }
) => {
try {
const response = await ConversationApi.toggleStatus({
const {
data: {
payload: {
current_status: updatedStatus,
snoozed_until: updatedSnoozedUntil,
} = {},
} = {},
} = await ConversationApi.toggleStatus({
conversationId,
status,
snoozedUntil,
});
commit(types.default.RESOLVE_CONVERSATION, {
commit(types.default.CHANGE_CONVERSATION_STATUS, {
conversationId,
status: response.data.payload.current_status,
status: updatedStatus,
snoozedUntil: updatedSnoozedUntil,
});
} catch (error) {
// Handle error
@ -223,11 +226,7 @@ const actions = {
data: { id, agent_last_seen_at: lastSeen },
} = await ConversationApi.markMessageRead(data);
setTimeout(
() =>
commit(types.default.MARK_MESSAGE_READ, {
id,
lastSeen,
}),
() => commit(types.default.MARK_MESSAGE_READ, { id, lastSeen }),
4000
);
} catch (error) {

View file

@ -69,9 +69,13 @@ export const mutations = {
Vue.set(chat.meta, 'team', team);
},
[types.default.RESOLVE_CONVERSATION](_state, { conversationId, status }) {
[types.default.CHANGE_CONVERSATION_STATUS](
_state,
{ conversationId, status, snoozedUntil }
) {
const conversation =
getters.getConversationById(_state)(conversationId) || {};
Vue.set(conversation, 'snoozed_until', snoozedUntil);
Vue.set(conversation, 'status', status);
},

View file

@ -217,15 +217,24 @@ describe('#actions', () => {
describe('#toggleStatus', () => {
it('sends correct mutations if toggle status is successful', async () => {
axios.post.mockResolvedValue({
data: { payload: { conversation_id: 1, current_status: 'resolved' } },
data: {
payload: {
conversation_id: 1,
current_status: 'snoozed',
snoozed_until: null,
},
},
});
await actions.toggleStatus(
{ commit },
{ conversationId: 1, status: 'resolved' }
{ conversationId: 1, status: 'snoozed' }
);
expect(commit).toHaveBeenCalledTimes(1);
expect(commit.mock.calls).toEqual([
['RESOLVE_CONVERSATION', { conversationId: 1, status: 'resolved' }],
[
'CHANGE_CONVERSATION_STATUS',
{ conversationId: 1, status: 'snoozed', snoozedUntil: null },
],
]);
});
});

View file

@ -161,7 +161,7 @@ describe('#mutations', () => {
});
});
describe('#RESOLVE_CONVERSATION', () => {
describe('#CHANGE_CONVERSATION_STATUS', () => {
it('updates the conversation status correctly', () => {
const state = {
allConversations: [
@ -173,7 +173,7 @@ describe('#mutations', () => {
],
};
mutations[types.RESOLVE_CONVERSATION](state, {
mutations[types.CHANGE_CONVERSATION_STATUS](state, {
conversationId: '1',
status: 'resolved',
});

View file

@ -23,7 +23,7 @@ export default {
SET_CURRENT_CHAT_WINDOW: 'SET_CURRENT_CHAT_WINDOW',
CLEAR_CURRENT_CHAT_WINDOW: 'CLEAR_CURRENT_CHAT_WINDOW',
CLEAR_ALL_MESSAGES: 'CLEAR_ALL_MESSAGES',
RESOLVE_CONVERSATION: 'RESOLVE_CONVERSATION',
CHANGE_CONVERSATION_STATUS: 'CHANGE_CONVERSATION_STATUS',
ADD_CONVERSATION: 'ADD_CONVERSATION',
UPDATE_CONVERSATION: 'UPDATE_CONVERSATION',
MUTE_CONVERSATION: 'MUTE_CONVERSATION',

View file

@ -4,12 +4,13 @@ class Conversations::EventDataPresenter < SimpleDelegator
additional_attributes: additional_attributes,
can_reply: can_reply?,
channel: inbox.try(:channel_type),
contact_inbox: contact_inbox,
id: display_id,
inbox_id: inbox_id,
contact_inbox: contact_inbox,
messages: push_messages,
meta: push_meta,
status: status,
snoozed_until: snoozed_until,
unread_count: unread_incoming_messages.count,
**push_timestamps
}

View file

@ -3,6 +3,7 @@ end
json.payload do
json.success @status
json.current_status @conversation.status
json.conversation_id @conversation.display_id
json.current_status @conversation.status
json.snoozed_until @conversation.snoozed_until
end

View file

@ -24,16 +24,17 @@ else
json.messages conversation.unread_messages.includes([:user, { attachments: [{ file_attachment: [:blob] }] }]).last(10).map(&:push_event_data)
end
json.inbox_id conversation.inbox_id
json.status conversation.status
json.muted conversation.muted?
json.can_reply conversation.can_reply?
json.timestamp conversation.last_activity_at.to_i
json.contact_last_seen_at conversation.contact_last_seen_at.to_i
json.account_id conversation.account_id
json.additional_attributes conversation.additional_attributes
json.agent_last_seen_at conversation.agent_last_seen_at.to_i
json.assignee_last_seen_at conversation.assignee_last_seen_at.to_i
json.unread_count conversation.unread_incoming_messages.count
json.additional_attributes conversation.additional_attributes
json.can_reply conversation.can_reply?
json.contact_last_seen_at conversation.contact_last_seen_at.to_i
json.custom_attributes conversation.custom_attributes
json.account_id conversation.account_id
json.inbox_id conversation.inbox_id
json.labels conversation.label_list
json.muted conversation.muted?
json.snoozed_until conversation.snoozed_until
json.status conversation.status
json.timestamp conversation.last_activity_at.to_i
json.unread_count conversation.unread_incoming_messages.count

View file

@ -354,6 +354,7 @@ RSpec.describe Conversation, type: :model do
timestamp: conversation.last_activity_at.to_i,
can_reply: true,
channel: 'Channel::WebWidget',
snoozed_until: conversation.snoozed_until,
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
unread_count: 0

View file

@ -22,6 +22,7 @@ RSpec.describe Conversations::EventDataPresenter do
can_reply: conversation.can_reply?,
channel: conversation.inbox.channel_type,
timestamp: conversation.last_activity_at.to_i,
snoozed_until: conversation.snoozed_until,
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
unread_count: 0