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