feat: Ability to snooze conversations (#2682)

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
This commit is contained in:
Sojan Jose
2021-07-23 15:24:07 +05:30
committed by GitHub
parent 4d45ac3bfc
commit d955d8e7dc
21 changed files with 270 additions and 26 deletions

View File

@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseController
include Events::Types
include DateRangeHelper
before_action :conversation, except: [:index, :meta, :search, :create]
before_action :contact_inbox, only: [:create]
@@ -49,9 +50,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def toggle_status
if params[:status]
status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status
@status = @conversation.save
set_conversation_status
@status = @conversation.save!
else
@status = @conversation.toggle_status
end
@@ -74,6 +74,12 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
private
def set_conversation_status
status = params[:status] == 'bot' ? 'pending' : params[:status]
@conversation.status = status
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]
end
def trigger_typing_event(event)
user = current_user.presence || @resource
Rails.configuration.dispatcher.dispatch(event, Time.zone.now, conversation: @conversation, user: user)
@@ -115,7 +121,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
inbox_id: @contact_inbox.inbox_id,
contact_id: @contact_inbox.contact_id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: additional_attributes
additional_attributes: additional_attributes,
snoozed_until: params[:snoozed_until]
}.merge(status)
end

View File

@@ -28,9 +28,10 @@ class ConversationApi extends ApiClient {
});
}
toggleStatus({ conversationId, status }) {
toggleStatus({ conversationId, status, snoozedUntil = null }) {
return axios.post(`${this.url}/${conversationId}/toggle_status`, {
status,
snoozed_until: snoozedUntil,
});
}

View File

@@ -69,6 +69,7 @@ describe('#ConversationAPI', () => {
`/api/v1/conversations/12/toggle_status`,
{
status: 'online',
snoozed_until: null,
}
);
});

View File

@@ -24,7 +24,7 @@
{{ this.$t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button>
<woot-button
v-else-if="isPending"
v-else-if="showOpenButton"
class-names="resolve"
color-scheme="primary"
icon="ion-person"
@@ -34,7 +34,7 @@
{{ this.$t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button>
<woot-button
v-if="showDropDown"
v-if="showAdditionalActions"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="ion-arrow-down-b"
@@ -43,7 +43,7 @@
/>
</div>
<div
v-if="showDropdown"
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open"
>
@@ -56,6 +56,40 @@
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-sub-menu
v-if="isOpen"
:title="this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TITLE')"
>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="() => toggleStatus(STATUS_TYPE.SNOOZED, null)"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_REPLY') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.tomorrow)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.TOMORROW') }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-item>
<woot-button
variant="clear"
@click="
() => toggleStatus(STATUS_TYPE.SNOOZED, snoozeTimes.nextWeek)
"
>
{{ this.$t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE.NEXT_WEEK') }}
</woot-button>
</woot-dropdown-item>
</woot-dropdown-sub-menu>
</woot-dropdown-menu>
</div>
</div>
@@ -67,20 +101,29 @@ import { mixin as clickaway } from 'vue-clickaway';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from '../../constants';
import {
getUnixTime,
addHours,
addWeeks,
startOfTomorrow,
startOfWeek,
} from 'date-fns';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
WootDropdownSubMenu,
},
mixins: [clickaway, alertMixin],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showDropdown: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
};
},
@@ -97,30 +140,47 @@ export default {
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() {
if (this.isPending) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
showDropDown() {
return !this.isPending;
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
},
snoozeTimes() {
return {
// tomorrow = 9AM next day
tomorrow: getUnixTime(addHours(startOfTomorrow(), 9)),
// next week = 9AM Monday, next week
nextWeek: getUnixTime(
addHours(startOfWeek(addWeeks(new Date(), 1), { weekStartsOn: 1 }), 9)
),
};
},
},
methods: {
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() {
this.showDropdown = false;
this.showActionsDropdown = false;
},
openDropdown() {
this.showDropdown = true;
this.showActionsDropdown = true;
},
toggleStatus(status) {
toggleStatus(status, snoozedUntil) {
this.closeDropdown();
this.isLoading = true;
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));

View File

@@ -9,6 +9,7 @@ export default {
OPEN: 'open',
RESOLVED: 'resolved',
PENDING: 'pending',
SNOOZED: 'snoozed',
},
};
export const DEFAULT_REDIRECT_URL = '/app/';

View File

@@ -49,6 +49,10 @@
{
"TEXT": "Pending",
"VALUE": "pending"
},
{
"TEXT": "Snoozed",
"VALUE": "snoozed"
}
],
"ATTACHMENTS": {

View File

@@ -41,7 +41,13 @@
"DETAILS": "details"
},
"RESOLVE_DROPDOWN": {
"MARK_PENDING": "Mark as pending"
"MARK_PENDING": "Mark as pending",
"SNOOZE": {
"TITLE": "Snooze until",
"NEXT_REPLY": "Next reply",
"TOMORROW": "Tomorrow",
"NEXT_WEEK": "Next week"
}
},
"FOOTER": {
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",

View File

@@ -135,11 +135,15 @@ const actions = {
commit(types.default.ASSIGN_TEAM, team);
},
toggleStatus: async ({ commit }, { conversationId, status }) => {
toggleStatus: async (
{ commit },
{ conversationId, status, snoozedUntil = null }
) => {
try {
const response = await ConversationApi.toggleStatus({
conversationId,
status,
snoozedUntil,
});
commit(
types.default.RESOLVE_CONVERSATION,

View File

@@ -0,0 +1,46 @@
<template>
<li class="sub-menu-container">
<div class="sub-menu-title">
<span class="small">{{ title }}</span>
</div>
<ul class="sub-menu-li-container">
<slot></slot>
</ul>
</li>
</template>
<script>
export default {
name: 'WootDropdownMenu',
componentName: 'WootDropdownMenu',
props: {
title: {
type: String,
default: '',
},
},
};
</script>
<style lang="scss" scoped>
.sub-menu-container {
border-top: 1px solid var(--color-border);
margin-top: var(--space-micro);
&:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
}
.sub-menu-title {
padding: var(--space-one) var(--space-one) var(--space-smaller);
text-transform: uppercase;
.small {
color: var(--b-600);
font-weight: var(--font-weight-medium);
}
}
.sub-menu-li-container {
margin: 0;
}
</style>

View File

@@ -0,0 +1,7 @@
class Conversations::ReopenSnoozedConversationsJob < ApplicationJob
queue_as :low
def perform
Conversation.where(status: :snoozed).where(snoozed_until: 3.days.ago..Time.current).all.each(&:open!)
end
end

View File

@@ -6,5 +6,8 @@ class TriggerScheduledItemsJob < ApplicationJob
Campaign.where(campaign_type: :one_off, campaign_status: :active).where(scheduled_at: 3.days.ago..Time.current).all.each do |campaign|
Campaigns::TriggerOneoffCampaignJob.perform_later(campaign)
end
# Job to reopen snoozed conversations
Conversations::ReopenSnoozedConversationsJob.perform_later
end
end

View File

@@ -8,6 +8,7 @@
# contact_last_seen_at :datetime
# identifier :string
# last_activity_at :datetime not null
# snoozed_until :datetime
# status :integer default("open"), not null
# uuid :uuid not null
# created_at :datetime not null
@@ -45,7 +46,7 @@ class Conversation < ApplicationRecord
validates :inbox_id, presence: true
before_validation :validate_additional_attributes
enum status: { open: 0, resolved: 1, pending: 2 }
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }
scope :latest, -> { order(last_activity_at: :desc) }
scope :unassigned, -> { where(assignee_id: nil) }
@@ -64,6 +65,7 @@ class Conversation < ApplicationRecord
has_one :csat_survey_response, dependent: :destroy
has_many :notifications, as: :primary_actor, dependent: :destroy
before_save :ensure_snooze_until_reset
before_create :mark_conversation_pending_if_bot
# wanted to change this to after_update commit. But it ended up creating a loop
@@ -91,7 +93,7 @@ class Conversation < ApplicationRecord
def toggle_status
# FIXME: implement state machine with aasm
self.status = open? ? :resolved : :open
self.status = :open if pending?
self.status = :open if pending? || snoozed?
save
end
@@ -140,6 +142,10 @@ class Conversation < ApplicationRecord
private
def ensure_snooze_until_reset
self.snoozed_until = nil unless snoozed?
end
def validate_additional_attributes
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end

View File

@@ -164,7 +164,10 @@ class Message < ApplicationRecord
end
def reopen_conversation
conversation.open! if incoming? && conversation.resolved? && !conversation.muted?
return if conversation.muted?
return unless incoming?
conversation.open! if conversation.resolved? || conversation.snoozed?
end
def execute_message_template_hooks