feat: Inbox item actions (#8838)

* feat: Inbox item actions

* feat: add inbox id in push event data

* Update InboxList.vue

* feat: complete actions

* Update InboxList.vue

* Update InboxView.vue

* chore: code cleanup

* chore: fix specs

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2024-02-02 11:58:47 +05:30
committed by GitHub
parent 33e98bf61a
commit d3c1fce761
15 changed files with 242 additions and 54 deletions

View File

@@ -2,7 +2,7 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
RESULTS_PER_PAGE = 15 RESULTS_PER_PAGE = 15
include DateRangeHelper 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_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index] before_action :set_current_page, only: [:index]
@@ -29,6 +29,11 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification render json: @notification
end end
def unread
@notification.update(read_at: nil)
render json: @notification
end
def destroy def destroy
@notification.destroy @notification.destroy
head :ok head :ok

View File

@@ -25,9 +25,17 @@ class NotificationsAPI extends ApiClient {
}); });
} }
unRead(id) {
return axios.post(`${this.url}/${id}/unread`);
}
readAll() { readAll() {
return axios.post(`${this.url}/read_all`); return axios.post(`${this.url}/read_all`);
} }
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
} }
export default new NotificationsAPI(); export default new NotificationsAPI();

View File

@@ -102,3 +102,12 @@ export const OPEN_AI_EVENTS = Object.freeze({
export const GENERAL_EVENTS = Object.freeze({ export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar', 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',
});

View File

@@ -2,7 +2,7 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import InboxCard from './components/InboxCard.vue'; import InboxCard from './components/InboxCard.vue';
import InboxListHeader from './components/InboxListHeader.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'; import IntersectionObserver from 'dashboard/components/IntersectionObserver.vue';
export default { export default {
components: { components: {
@@ -37,27 +37,15 @@ export default {
}, },
methods: { methods: {
openConversation(notification) { openConversation(notification) {
const { const { notification_type: notificationType } = notification;
primary_actor_id: primaryActorId, this.$track(INBOX_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, {
primary_actor_type: primaryActorType,
primary_actor: { id: conversationId },
notification_type: notificationType,
} = notification;
this.$track(ACCOUNT_EVENTS.OPEN_CONVERSATION_VIA_NOTIFICATION, {
notificationType, notificationType,
}); });
this.$store.dispatch('notifications/read', {
primaryActorId, this.markNotificationAsRead(notification);
primaryActorType,
unreadCount: this.meta.unreadCount,
});
this.$router.push(
`/app/accounts/${this.accountId}/conversations/${conversationId}`
);
}, },
onMarkAllDoneClick() { onMarkAllDoneClick() {
this.$track(ACCOUNT_EVENTS.MARK_AS_READ_NOTIFICATIONS); this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll'); this.$store.dispatch('notifications/readAll');
}, },
loadMoreNotifications() { loadMoreNotifications() {
@@ -65,6 +53,35 @@ export default {
this.$store.dispatch('notifications/index', { page: this.page + 1 }); this.$store.dispatch('notifications/index', { page: this.page + 1 });
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,
});
},
}, },
}; };
</script> </script>
@@ -81,6 +98,10 @@ export default {
v-for="notificationItem in records" v-for="notificationItem in records"
:key="notificationItem.id" :key="notificationItem.id"
:notification-item="notificationItem" :notification-item="notificationItem"
@open-conversation="openConversation"
@mark-notification-as-read="markNotificationAsRead"
@mark-notification-as-unread="markNotificationAsUnRead"
@delete-notification="deleteNotification"
/> />
<div v-if="uiFlags.isFetching" class="text-center"> <div v-if="uiFlags.isFetching" class="text-center">
<span class="spinner mt-4 mb-4" /> <span class="spinner mt-4 mb-4" />

View File

@@ -45,7 +45,9 @@
<inbox-context-menu <inbox-context-menu
v-if="isContextMenuOpen" v-if="isContextMenuOpen"
:context-menu-position="contextMenuPosition" :context-menu-position="contextMenuPosition"
:menu-items="menuItems"
@close="closeContextMenu" @close="closeContextMenu"
@click="handleAction"
/> />
</div> </div>
</template> </template>
@@ -106,6 +108,27 @@ export default {
); );
return this.shortTimestamp(dynamicTime, true); 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() { unmounted() {
this.closeContextMenu(); this.closeContextMenu();
@@ -127,6 +150,20 @@ export default {
}; };
this.isContextMenuOpen = true; 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:
}
},
}, },
}; };
</script> </script>

View File

@@ -28,28 +28,10 @@ export default {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
}, menuItems: {
data() { type: Array,
return { default: () => [],
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'),
},
],
};
}, },
methods: { methods: {
handleClose() { handleClose() {

View File

@@ -188,6 +188,7 @@ export default {
notificationType, notificationType,
}); });
this.$store.dispatch('notifications/read', { this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId, primaryActorId,
primaryActorType, primaryActorType,
unreadCount: this.meta.unreadCount, unreadCount: this.meta.unreadCount,

View File

@@ -58,6 +58,7 @@ export default {
notificationType, notificationType,
}); });
this.$store.dispatch('notifications/read', { this.$store.dispatch('notifications/read', {
id: notification.id,
primaryActorId, primaryActorId,
primaryActorType, primaryActorType,
unreadCount: this.meta.unreadCount, unreadCount: this.meta.unreadCount,

View File

@@ -48,14 +48,26 @@ export const actions = {
}, },
read: async ( read: async (
{ commit }, { commit },
{ primaryActorType, primaryActorId, unreadCount } { id, primaryActorType, primaryActorId, unreadCount }
) => { ) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try { try {
await NotificationsAPI.read(primaryActorType, primaryActorId); await NotificationsAPI.read(primaryActorType, primaryActorId);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1); 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) { } 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 }) => { 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) { addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data); commit(types.ADD_NOTIFICATION, data);
}, },

View File

@@ -13,6 +13,7 @@ const state = {
isFetching: false, isFetching: false,
isFetchingItem: false, isFetchingItem: false,
isUpdating: false, isUpdating: false,
isDeleting: false,
isUpdatingUnreadCount: false, isUpdatingUnreadCount: false,
isAllNotificationsLoaded: false, isAllNotificationsLoaded: false,
}, },

View File

@@ -34,12 +34,8 @@ export const mutations = {
}); });
}); });
}, },
[types.UPDATE_NOTIFICATION]: ($state, primaryActorId) => { [types.UPDATE_NOTIFICATION]: ($state, { id, read_at }) => {
Object.values($state.records).forEach(item => { Vue.set($state.records[id], 'read_at', read_at);
if (item.primary_actor_id === primaryActorId) {
Vue.set($state.records[item.id], 'read_at', true);
}
});
}, },
[types.UPDATE_ALL_NOTIFICATIONS]: $state => { [types.UPDATE_ALL_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => { Object.values($state.records).forEach(item => {

View File

@@ -94,18 +94,91 @@ describe('#actions', () => {
describe('#read', () => { describe('#read', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({}); 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([ expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true }],
[types.SET_NOTIFICATIONS_UNREAD_COUNT, 1], [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 () => { it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' }); axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.read({ commit })).rejects.toThrow(Error); 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', () => { describe('#readAll', () => {
it('sends correct actions if API is success', async () => { it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: 1 }); axios.post.mockResolvedValue({ data: 1 });

View File

@@ -75,7 +75,10 @@ describe('#mutations', () => {
1: { id: 1, primary_actor_id: 1 }, 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({ expect(state.records).toEqual({
1: { id: 1, primary_actor_id: 1, read_at: true }, 1: { id: 1, primary_actor_id: 1, read_at: true },
}); });

View File

@@ -176,6 +176,7 @@ Rails.application.routes.draw do
end end
member do member do
post :snooze post :snooze
post :unread
end end
end end
resource :notification_settings, only: [:show, :update] resource :notification_settings, only: [:show, :update]

View File

@@ -154,7 +154,7 @@ RSpec.describe 'Notifications API', type: :request do
end end
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(:admin) { create(:user, account: account, role: :administrator) }
let!(:notification) { create(:notification, account: account, user: admin) } let!(:notification) { create(:notification, account: account, user: admin) }
@@ -181,4 +181,30 @@ RSpec.describe 'Notifications API', type: :request do
end end
end 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 end