diff --git a/app/controllers/api/v1/accounts/notifications_controller.rb b/app/controllers/api/v1/accounts/notifications_controller.rb index a3d8df27e..54a03c783 100644 --- a/app/controllers/api/v1/accounts/notifications_controller.rb +++ b/app/controllers/api/v1/accounts/notifications_controller.rb @@ -2,7 +2,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro RESULTS_PER_PAGE = 15 include DateRangeHelper - before_action :fetch_notification, only: [:update, :destroy, :snooze] + before_action :fetch_notification, only: [:update, :destroy, :snooze, :unread] before_action :set_primary_actor, only: [:read_all] before_action :set_current_page, only: [:index] @@ -29,6 +29,11 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro render json: @notification end + def unread + @notification.update(read_at: nil) + render json: @notification + end + def destroy @notification.destroy head :ok diff --git a/app/javascript/dashboard/api/notifications.js b/app/javascript/dashboard/api/notifications.js index e13bc78a6..aa6413483 100644 --- a/app/javascript/dashboard/api/notifications.js +++ b/app/javascript/dashboard/api/notifications.js @@ -25,9 +25,17 @@ class NotificationsAPI extends ApiClient { }); } + unRead(id) { + return axios.post(`${this.url}/${id}/unread`); + } + readAll() { return axios.post(`${this.url}/read_all`); } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } } export default new NotificationsAPI(); diff --git a/app/javascript/dashboard/helper/AnalyticsHelper/events.js b/app/javascript/dashboard/helper/AnalyticsHelper/events.js index 30f82d554..59361e446 100644 --- a/app/javascript/dashboard/helper/AnalyticsHelper/events.js +++ b/app/javascript/dashboard/helper/AnalyticsHelper/events.js @@ -102,3 +102,12 @@ export const OPEN_AI_EVENTS = Object.freeze({ export const GENERAL_EVENTS = Object.freeze({ COMMAND_BAR: 'Used commandbar', }); + +export const INBOX_EVENTS = Object.freeze({ + OPEN_CONVERSATION_VIA_INBOX: 'Opened conversation via inbox', + MARK_NOTIFICATION_AS_READ: 'Marked notification as read', + MARK_ALL_NOTIFICATIONS_AS_READ: 'Marked all notifications as read', + MARK_NOTIFICATION_AS_UNREAD: 'Marked notification as unread', + DELETE_NOTIFICATION: 'Deleted notification', + DELETE_ALL_NOTIFICATIONS: 'Deleted all notifications', +}); diff --git a/app/javascript/dashboard/routes/dashboard/inbox/InboxList.vue b/app/javascript/dashboard/routes/dashboard/inbox/InboxList.vue index 441030584..b721ff395 100644 --- a/app/javascript/dashboard/routes/dashboard/inbox/InboxList.vue +++ b/app/javascript/dashboard/routes/dashboard/inbox/InboxList.vue @@ -2,7 +2,7 @@ import { mapGetters } from 'vuex'; import InboxCard from './components/InboxCard.vue'; import InboxListHeader from './components/InboxListHeader.vue'; -import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events'; +import { INBOX_EVENTS } from '../../../helper/AnalyticsHelper/events'; import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue'; export default { components: { @@ -37,27 +37,15 @@ export default { }, methods: { openConversation(notification) { - const { - primary_actor_id: primaryActorId, - primary_actor_type: primaryActorType, - primary_actor: { id: conversationId }, - notification_type: notificationType, - } = notification; - - this.$track(ACCOUNT_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, { + const { notification_type: notificationType } = notification; + this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, { notificationType, }); - this.$store.dispatch('notifications/read', { - primaryActorId, - primaryActorType, - unreadCount: this.meta.unreadCount, - }); - this.$router.push( - `/app/accounts/${this.accountId}/conversations/${conversationId}` - ); + + this.markNotificationAsRead(notification); }, onMarkAllDoneClick() { - this.$track(ACCOUNT_EVENTS.MARK_AS_READ_NOTIFICATIONS); + this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ); this.$store.dispatch('notifications/readAll'); }, loadMoreNotifications() { @@ -65,6 +53,35 @@ export default { this.$store.dispatch('notifications/index', { page: this.page + 1 }); this.page += 1; }, + markNotificationAsRead(notification) { + this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_READ); + const { + id, + primary_actor_id: primaryActorId, + primary_actor_type: primaryActorType, + } = notification; + this.$store.dispatch('notifications/read', { + id, + primaryActorId, + primaryActorType, + unreadCount: this.meta.unreadCount, + }); + }, + markNotificationAsUnRead(notification) { + this.$track(INBOX_EVENTS.MARK_NOTIFICATION_AS_UNREAD); + const { id } = notification; + this.$store.dispatch('notifications/unread', { + id, + }); + }, + deleteNotification(notification) { + this.$track(INBOX_EVENTS.DELETE_NOTIFICATION); + this.$store.dispatch('notifications/delete', { + notification, + unread_count: this.meta.unreadCount, + count: this.meta.count, + }); + }, }, }; @@ -81,6 +98,10 @@ export default { v-for="notificationItem in records" :key="notificationItem.id" :notification-item="notificationItem" + @open-conversation="openConversation" + @mark-notification-as-read="markNotificationAsRead" + @mark-notification-as-unread="markNotificationAsUnRead" + @delete-notification="deleteNotification" />
diff --git a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxCard.vue b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxCard.vue index bfde966b4..c185c010d 100644 --- a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxCard.vue +++ b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxCard.vue @@ -45,7 +45,9 @@
@@ -106,6 +108,27 @@ export default { ); return this.shortTimestamp(dynamicTime, true); }, + menuItems() { + const items = [ + { + key: 'delete', + label: this.$t('INBOX.MENU_ITEM.DELETE'), + }, + ]; + + if (!this.isUnread) { + items.push({ + key: 'mark_as_unread', + label: this.$t('INBOX.MENU_ITEM.MARK_AS_UNREAD'), + }); + } else { + items.push({ + key: 'mark_as_read', + label: this.$t('INBOX.MENU_ITEM.MARK_AS_READ'), + }); + } + return items; + }, }, unmounted() { this.closeContextMenu(); @@ -127,6 +150,20 @@ export default { }; this.isContextMenuOpen = true; }, + handleAction(key) { + switch (key) { + case 'mark_as_read': + this.$emit('mark-notification-as-read', this.notificationItem); + break; + case 'mark_as_unread': + this.$emit('mark-notification-as-unread', this.notificationItem); + break; + case 'delete': + this.$emit('delete-notification', this.notificationItem); + break; + default: + } + }, }, }; diff --git a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue index c314284f7..f6563ac7c 100644 --- a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue +++ b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxContextMenu.vue @@ -28,28 +28,10 @@ export default { type: Object, default: () => ({}), }, - }, - data() { - return { - menuItems: [ - { - key: 'mark_as_read', - label: this.$t('INBOX.MENU_ITEM.MARK_AS_READ'), - }, - { - key: 'mark_as_unread', - label: this.$t('INBOX.MENU_ITEM.MARK_AS_UNREAD'), - }, - { - key: 'snooze', - label: this.$t('INBOX.MENU_ITEM.SNOOZE'), - }, - { - key: 'delete', - label: this.$t('INBOX.MENU_ITEM.DELETE'), - }, - ], - }; + menuItems: { + type: Array, + default: () => [], + }, }, methods: { handleClose() { diff --git a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanel.vue b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanel.vue index e5b008d8b..0d76d611b 100644 --- a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationPanel.vue @@ -188,6 +188,7 @@ export default { notificationType, }); this.$store.dispatch('notifications/read', { + id: notification.id, primaryActorId, primaryActorType, unreadCount: this.meta.unreadCount, diff --git a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue index 058305a7a..7ab8369be 100644 --- a/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue +++ b/app/javascript/dashboard/routes/dashboard/notifications/components/NotificationsView.vue @@ -58,6 +58,7 @@ export default { notificationType, }); this.$store.dispatch('notifications/read', { + id: notification.id, primaryActorId, primaryActorType, unreadCount: this.meta.unreadCount, diff --git a/app/javascript/dashboard/store/modules/notifications/actions.js b/app/javascript/dashboard/store/modules/notifications/actions.js index 96021c177..559b560d4 100644 --- a/app/javascript/dashboard/store/modules/notifications/actions.js +++ b/app/javascript/dashboard/store/modules/notifications/actions.js @@ -48,14 +48,26 @@ export const actions = { }, read: async ( { commit }, - { primaryActorType, primaryActorId, unreadCount } + { id, primaryActorType, primaryActorId, unreadCount } ) => { + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }); try { await NotificationsAPI.read(primaryActorType, primaryActorId); commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1); - commit(types.UPDATE_NOTIFICATION, primaryActorId); + commit(types.UPDATE_NOTIFICATION, { id, read_at: new Date() }); + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); } catch (error) { - throw new Error(error); + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); + } + }, + unread: async ({ commit }, { id }) => { + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }); + try { + await NotificationsAPI.unRead(id); + commit(types.UPDATE_NOTIFICATION, { id, read_at: null }); + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); + } catch (error) { + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }); } }, readAll: async ({ commit }) => { @@ -71,6 +83,18 @@ export const actions = { } }, + delete: async ({ commit }, { notification, count, unreadCount }) => { + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }); + try { + await NotificationsAPI.delete(notification.id); + commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1); + commit(types.DELETE_NOTIFICATION, { notification, count, unreadCount }); + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }); + } catch (error) { + commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }); + } + }, + addNotification({ commit }, data) { commit(types.ADD_NOTIFICATION, data); }, diff --git a/app/javascript/dashboard/store/modules/notifications/index.js b/app/javascript/dashboard/store/modules/notifications/index.js index 095bde09e..e3b31a637 100644 --- a/app/javascript/dashboard/store/modules/notifications/index.js +++ b/app/javascript/dashboard/store/modules/notifications/index.js @@ -13,6 +13,7 @@ const state = { isFetching: false, isFetchingItem: false, isUpdating: false, + isDeleting: false, isUpdatingUnreadCount: false, isAllNotificationsLoaded: false, }, diff --git a/app/javascript/dashboard/store/modules/notifications/mutations.js b/app/javascript/dashboard/store/modules/notifications/mutations.js index e33f7e77a..14f2bbb0f 100644 --- a/app/javascript/dashboard/store/modules/notifications/mutations.js +++ b/app/javascript/dashboard/store/modules/notifications/mutations.js @@ -34,12 +34,8 @@ export const mutations = { }); }); }, - [types.UPDATE_NOTIFICATION]: ($state, primaryActorId) => { - Object.values($state.records).forEach(item => { - if (item.primary_actor_id === primaryActorId) { - Vue.set($state.records[item.id], 'read_at', true); - } - }); + [types.UPDATE_NOTIFICATION]: ($state, { id, read_at }) => { + Vue.set($state.records[id], 'read_at', read_at); }, [types.UPDATE_ALL_NOTIFICATIONS]: $state => { Object.values($state.records).forEach(item => { diff --git a/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js b/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js index 5a491b93f..be3af6f06 100644 --- a/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js +++ b/app/javascript/dashboard/store/modules/specs/notifications/actions.spec.js @@ -94,18 +94,91 @@ describe('#actions', () => { describe('#read', () => { it('sends correct actions if API is success', async () => { axios.post.mockResolvedValue({}); - await actions.read({ commit }, { unreadCount: 2, primaryActorId: 1 }); + await actions.read( + { commit }, + { id: 1, unreadCount: 2, primaryActorId: 1 } + ); expect(commit.mock.calls).toEqual([ + [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }], [types.SET_NOTIFICATIONS_UNREAD_COUNT, 1], - [types.UPDATE_NOTIFICATION, 1], + [types.UPDATE_NOTIFICATION, { id: 1, read_at: expect.any(Date) }], + [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }], ]); }); it('sends correct actions if API is error', async () => { axios.post.mockRejectedValue({ message: 'Incorrect header' }); await expect(actions.read({ commit })).rejects.toThrow(Error); + await actions.read( + { commit }, + { id: 1, unreadCount: 2, primaryActorId: 1 } + ); + expect(commit.mock.calls).toEqual([ + [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }], + [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }], + ]); }); }); + describe('#unread', () => { + it('sends correct actions if API is success', async () => { + axios.post.mockResolvedValue({}); + await actions.unread({ commit }, { id: 1 }); + expect(commit.mock.calls).toEqual([ + ['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: true }], + ['UPDATE_NOTIFICATION', { id: 1, read_at: null }], + ['SET_NOTIFICATIONS_UI_FLAG', { isUpdating: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.post.mockRejectedValue({ message: 'Incorrect header' }); + await expect(actions.unread({ commit })).rejects.toThrow(Error); + await actions.unread({ commit }, { id: 1 }); + expect(commit.mock.calls).toEqual([ + [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }], + [types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false }], + ]); + }); + }); + + describe('#delete', () => { + it('sends correct actions if API is success', async () => { + axios.delete.mockResolvedValue({}); + await actions.delete( + { commit }, + { + notification: { id: 1 }, + count: 2, + unreadCount: 1, + } + ); + + expect(commit.mock.calls).toEqual([ + [types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }], + [types.SET_NOTIFICATIONS_UNREAD_COUNT, 0], + [ + types.DELETE_NOTIFICATION, + { notification: { id: 1 }, count: 2, unreadCount: 1 }, + ], + [types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }], + ]); + }); + it('sends correct actions if API is error', async () => { + axios.delete.mockRejectedValue({ message: 'Incorrect header' }); + await expect(actions.delete({ commit })).rejects.toThrow(Error); + await actions.delete( + { commit }, + { + notification: { id: 1 }, + count: 2, + unreadCount: 1, + } + ); + expect(commit.mock.calls).toEqual([ + [types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }], + [types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }], + ]); + }); + }); describe('#readAll', () => { it('sends correct actions if API is success', async () => { axios.post.mockResolvedValue({ data: 1 }); diff --git a/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js index 02cea4837..1efb06e59 100644 --- a/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js +++ b/app/javascript/dashboard/store/modules/specs/notifications/mutations.spec.js @@ -75,7 +75,10 @@ describe('#mutations', () => { 1: { id: 1, primary_actor_id: 1 }, }, }; - mutations[types.UPDATE_NOTIFICATION](state, 1); + mutations[types.UPDATE_NOTIFICATION](state, { + id: 1, + read_at: true, + }); expect(state.records).toEqual({ 1: { id: 1, primary_actor_id: 1, read_at: true }, }); diff --git a/config/routes.rb b/config/routes.rb index ec838c107..70b19f504 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -176,6 +176,7 @@ Rails.application.routes.draw do end member do post :snooze + post :unread end end resource :notification_settings, only: [:show, :update] diff --git a/spec/controllers/api/v1/accounts/notifications_controller_spec.rb b/spec/controllers/api/v1/accounts/notifications_controller_spec.rb index cadc212d4..55ebf997c 100644 --- a/spec/controllers/api/v1/accounts/notifications_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/notifications_controller_spec.rb @@ -154,7 +154,7 @@ RSpec.describe 'Notifications API', type: :request do end end - describe 'PATCH /api/v1/accounts/{account.id}/notifications/:id/snooze' do + describe 'POST /api/v1/accounts/{account.id}/notifications/:id/snooze' do let(:admin) { create(:user, account: account, role: :administrator) } let!(:notification) { create(:notification, account: account, user: admin) } @@ -181,4 +181,30 @@ RSpec.describe 'Notifications API', type: :request do end end end + + describe 'POST /api/v1/accounts/{account.id}/notifications/:id/unread' do + let(:admin) { create(:user, account: account, role: :administrator) } + let!(:notification) { create(:notification, account: account, user: admin) } + + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + post "/api/v1/accounts/#{account.id}/notifications/#{notification.id}/unread" + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an authenticated user' do + let(:admin) { create(:user, account: account, role: :administrator) } + + it 'updates the notification read at' do + post "/api/v1/accounts/#{account.id}/notifications/#{notification.id}/unread", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(notification.reload.read_at).to be_nil + end + end + end end