feat: Delete all/read notifications (#8844)

This commit is contained in:
Muhsin Keloth
2024-02-05 13:33:05 +05:30
committed by GitHub
parent 45e630fc60
commit 39e27d2a23
14 changed files with 239 additions and 44 deletions

View File

@@ -39,6 +39,15 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
head :ok head :ok
end end
def destroy_all
if params[:type] == 'read'
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :read)
else
::Notification::DeleteNotificationJob.perform_later(Current.user, type: :all)
end
head :ok
end
def unread_count def unread_count
@unread_count = notification_finder.unread_count @unread_count = notification_finder.unread_count
render json: @unread_count render json: @unread_count

View File

@@ -36,6 +36,12 @@ class NotificationsAPI extends ApiClient {
delete(id) { delete(id) {
return axios.delete(`${this.url}/${id}`); return axios.delete(`${this.url}/${id}`);
} }
deleteAll({ type = 'all' }) {
return axios.post(`${this.url}/destroy_all`, {
type,
});
}
} }
export default new NotificationsAPI(); export default new NotificationsAPI();

View File

@@ -115,18 +115,6 @@ export default {
value: 'read', value: 'read',
selected: true, selected: true,
}, },
{
id: 3,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.LABELS'),
value: 'labels',
selected: false,
},
{
id: 4,
name: this.$t('INBOX.DISPLAY_MENU.DISPLAY_OPTIONS.CONVERSATION_ID'),
value: 'conversationId',
selected: false,
},
], ],
sortOptions: [ sortOptions: [
{ {
@@ -137,10 +125,6 @@ export default {
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.OLDEST'), name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.OLDEST'),
key: 'oldest', key: 'oldest',
}, },
{
name: this.$t('INBOX.DISPLAY_MENU.SORT_OPTIONS.PRIORITY'),
key: 'priority',
},
], ],
activeSort: 'newest', activeSort: 'newest',
}; };

View File

@@ -49,6 +49,7 @@
v-if="showInboxOptionMenu" v-if="showInboxOptionMenu"
v-on-clickaway="openInboxOptionsMenu" v-on-clickaway="openInboxOptionsMenu"
class="absolute top-9" class="absolute top-9"
@option-click="onInboxOptionMenuClick"
/> />
</div> </div>
</div> </div>
@@ -57,6 +58,7 @@
<script> <script>
import { mixin as clickaway } from 'vue-clickaway'; import { mixin as clickaway } from 'vue-clickaway';
import InboxOptionMenu from './InboxOptionMenu.vue'; import InboxOptionMenu from './InboxOptionMenu.vue';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import InboxDisplayMenu from './InboxDisplayMenu.vue'; import InboxDisplayMenu from './InboxDisplayMenu.vue';
export default { export default {
components: { components: {
@@ -71,12 +73,34 @@ export default {
}; };
}, },
methods: { methods: {
markAllRead() {
this.$track(INBOX_EVENTS.MARK_ALL_NOTIFICATIONS_AS_READ);
this.$store.dispatch('notifications/readAll');
},
deleteAll() {
this.$store.dispatch('notifications/deleteAll');
},
deleteAllRead() {
this.$store.dispatch('notifications/deleteAllRead');
},
openInboxDisplayMenu() { openInboxDisplayMenu() {
this.showInboxDisplayMenu = !this.showInboxDisplayMenu; this.showInboxDisplayMenu = !this.showInboxDisplayMenu;
}, },
openInboxOptionsMenu() { openInboxOptionsMenu() {
this.showInboxOptionMenu = !this.showInboxOptionMenu; this.showInboxOptionMenu = !this.showInboxOptionMenu;
}, },
onInboxOptionMenuClick(key) {
this.showInboxOptionMenu = false;
if (key === 'mark_all_read') {
this.markAllRead();
}
if (key === 'delete_all') {
this.deleteAll();
}
if (key === 'delete_all_read') {
this.deleteAllRead();
}
},
}, },
}; };
</script> </script>

View File

@@ -7,15 +7,7 @@
v-for="item in menuItems" v-for="item in menuItems"
:key="item.key" :key="item.key"
:label="item.label" :label="item.label"
@click="onMenuItemClick(item.key)" @click="onClick(item.key)"
/>
</div>
<div class="flex flex-col">
<menu-item
v-for="item in commonMenuItems"
:key="item.key"
:label="item.label"
@click="onMenuItemClick(item.key)"
/> />
</div> </div>
</div> </div>
@@ -30,24 +22,6 @@ export default {
data() { data() {
return { return {
menuItems: [ 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'),
},
],
commonMenuItems: [
{ {
key: 'mark_all_read', key: 'mark_all_read',
label: this.$t('INBOX.MENU_ITEM.MARK_ALL_READ'), label: this.$t('INBOX.MENU_ITEM.MARK_ALL_READ'),
@@ -64,7 +38,7 @@ export default {
}; };
}, },
methods: { methods: {
onMenuItemClick(key) { onClick(key) {
this.$emit('option-click', key); this.$emit('option-click', key);
}, },
}, },

View File

@@ -95,6 +95,30 @@ export const actions = {
} }
}, },
deleteAllRead: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'read',
});
commit(types.DELETE_READ_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'all',
});
commit(types.DELETE_ALL_NOTIFICATIONS);
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

@@ -61,4 +61,15 @@ export const mutations = {
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => { [types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true); Vue.set($state.uiFlags, 'isAllNotificationsLoaded', true);
}, },
[types.DELETE_READ_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
if (item.read_at) {
Vue.delete($state.records, item.id);
}
});
},
[types.DELETE_ALL_NOTIFICATIONS]: $state => {
Vue.set($state, 'records', {});
},
}; };

View File

@@ -219,4 +219,44 @@ describe('#actions', () => {
expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]); expect(commit.mock.calls).toEqual([[types.CLEAR_NOTIFICATIONS]]);
}); });
}); });
describe('deleteAllRead', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_READ_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAllRead({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('deleteAll', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.DELETE_ALL_NOTIFICATIONS],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await actions.deleteAll({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true }],
[types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false }],
]);
});
});
}); });

View File

@@ -155,4 +155,32 @@ describe('#mutations', () => {
expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true }); expect(state.uiFlags).toEqual({ isAllNotificationsLoaded: true });
}); });
}); });
describe('#DELETE_READ_NOTIFICATIONS', () => {
it('delete read notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_READ_NOTIFICATIONS](state);
expect(state.records).toEqual({
2: { id: 2, primary_actor_id: 2 },
});
});
});
describe('#DELETE_ALL_NOTIFICATIONS', () => {
it('delete all notifications', () => {
const state = {
records: {
1: { id: 1, primary_actor_id: 1, read_at: true },
2: { id: 2, primary_actor_id: 2 },
},
};
mutations[types.DELETE_ALL_NOTIFICATIONS](state);
expect(state.records).toEqual({});
});
});
}); });

View File

@@ -141,6 +141,8 @@ export default {
EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS', EDIT_NOTIFICATIONS: 'EDIT_NOTIFICATIONS',
UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE', UPDATE_NOTIFICATIONS_PRESENCE: 'UPDATE_NOTIFICATIONS_PRESENCE',
SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED', SET_ALL_NOTIFICATIONS_LOADED: 'SET_ALL_NOTIFICATIONS_LOADED',
DELETE_READ_NOTIFICATIONS: 'DELETE_READ_NOTIFICATIONS',
DELETE_ALL_NOTIFICATIONS: 'DELETE_ALL_NOTIFICATIONS',
// Contact Conversation // Contact Conversation
SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG', SET_CONTACT_CONVERSATIONS_UI_FLAG: 'SET_CONTACT_CONVERSATIONS_UI_FLAG',

View File

@@ -0,0 +1,15 @@
class Notification::DeleteNotificationJob < ApplicationJob
queue_as :low
def perform(user, type: :all)
ActiveRecord::Base.transaction do
if type == :all
# Delete all notifications
user.notifications.destroy_all
elsif type == :read
# Delete only read notifications
user.notifications.where.not(read_at: nil).destroy_all
end
end
end
end

View File

@@ -173,6 +173,7 @@ Rails.application.routes.draw do
collection do collection do
post :read_all post :read_all
get :unread_count get :unread_count
post :destroy_all
end end
member do member do
post :snooze post :snooze

View File

@@ -207,4 +207,43 @@ RSpec.describe 'Notifications API', type: :request do
end end
end end
end end
describe 'POST /api/v1/accounts/{account.id}/notifications/destroy_all' do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:notification1) { create(:notification, account: account, user: admin) }
let(:notification2) { 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/destroy_all"
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 'deletes all the read notifications' do
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :read)
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
headers: admin.create_new_auth_token,
params: { type: 'read' },
as: :json
expect(response).to have_http_status(:success)
end
it 'deletes all the notifications' do
expect(Notification::DeleteNotificationJob).to receive(:perform_later).with(admin, type: :all)
post "/api/v1/accounts/#{account.id}/notifications/destroy_all",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
end
end
end
end end

View File

@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe Notification::DeleteNotificationJob do
let(:user) { create(:user) }
let(:conversation) { create(:conversation) }
context 'when enqueuing the job' do
it 'enqueues the job to delete all notifications' do
expect do
described_class.perform_later(user.id, type: :all)
end.to have_enqueued_job(described_class).on_queue('low')
end
it 'enqueues the job to delete read notifications' do
expect do
described_class.perform_later(user.id, type: :read)
end.to have_enqueued_job(described_class).on_queue('low')
end
end
context 'when performing the job' do
before do
create(:notification, user: user, read_at: nil)
create(:notification, user: user, read_at: Time.current)
end
it 'deletes all notifications' do
described_class.perform_now(user, type: :all)
expect(user.notifications.count).to eq(0)
end
it 'deletes only read notifications' do
described_class.perform_now(user, type: :read)
expect(user.notifications.count).to eq(1)
expect(user.notifications.where(read_at: nil).count).to eq(1)
end
end
end