feat(notifications): filter alerts by category (#3718)

This commit is contained in:
obchain
2026-06-25 15:52:28 +05:30
committed by GitHub
parent c2e040a53c
commit af29f92c2d
16 changed files with 204 additions and 5 deletions
+2
View File
@@ -2611,6 +2611,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'الاجتماعات',
'notifications.category.reminders': 'التذكيرات',
'notifications.category.important': 'مهم',
'notifications.filterAll': 'الكل',
'notifications.filterEmpty': 'لا توجد تنبيهات في هذه الفئة',
'about.update.status.checking': 'جارٍ التحقق...',
'about.update.status.available': 'v{version} متاح',
'about.update.status.availableNoVersion': 'تحديث متاح',
+2
View File
@@ -2669,6 +2669,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'মিটিং',
'notifications.category.reminders': 'রিমাইন্ডার',
'notifications.category.important': 'গুরুত্বপূর্ণ',
'notifications.filterAll': 'সব',
'notifications.filterEmpty': 'এই বিভাগে কোনো সতর্কতা নেই',
'about.update.status.checking': 'পরীক্ষা হচ্ছে...',
'about.update.status.available': 'v{version} পাওয়া গেছে',
'about.update.status.availableNoVersion': 'আপডেট পাওয়া গেছে',
+2
View File
@@ -2730,6 +2730,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Treffen',
'notifications.category.reminders': 'Erinnerungen',
'notifications.category.important': 'Wichtig',
'notifications.filterAll': 'Alle',
'notifications.filterEmpty': 'Keine Benachrichtigungen in dieser Kategorie',
'about.update.status.checking': 'Überprüfen...',
'about.update.status.available': 'v{version} verfügbar',
'about.update.status.availableNoVersion': 'Update verfügbar',
+2
View File
@@ -3164,6 +3164,8 @@ const en: TranslationMap = {
'notifications.category.meetings': 'Meetings',
'notifications.category.reminders': 'Reminders',
'notifications.category.important': 'Important',
'notifications.filterAll': 'All',
'notifications.filterEmpty': 'No alerts in this category',
// About / Updates: status summary phrases
'about.update.status.checking': 'Checking...',
+2
View File
@@ -2716,6 +2716,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Reuniones',
'notifications.category.reminders': 'Recordatorios',
'notifications.category.important': 'Importante',
'notifications.filterAll': 'Todas',
'notifications.filterEmpty': 'No hay alertas en esta categoría',
'about.update.status.checking': 'Verificando...',
'about.update.status.available': 'v{version} disponible',
'about.update.status.availableNoVersion': 'Actualización disponible',
+2
View File
@@ -2728,6 +2728,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Réunions',
'notifications.category.reminders': 'Rappels',
'notifications.category.important': 'Important',
'notifications.filterAll': 'Tout',
'notifications.filterEmpty': 'Aucune alerte dans cette catégorie',
'about.update.status.checking': 'Vérification…',
'about.update.status.available': 'v{version} disponible',
'about.update.status.availableNoVersion': 'Mise à jour disponible',
+2
View File
@@ -2665,6 +2665,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'मीटिंग',
'notifications.category.reminders': 'रिमाइंडर',
'notifications.category.important': 'महत्वपूर्ण',
'notifications.filterAll': 'सभी',
'notifications.filterEmpty': 'इस श्रेणी में कोई सूचना नहीं',
'about.update.status.checking': 'चेक हो रहा है...',
'about.update.status.available': 'v{version} उपलब्ध है',
'about.update.status.availableNoVersion': 'अपडेट उपलब्ध है',
+2
View File
@@ -2669,6 +2669,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Rapat',
'notifications.category.reminders': 'Pengingat',
'notifications.category.important': 'Penting',
'notifications.filterAll': 'Semua',
'notifications.filterEmpty': 'Tidak ada peringatan di kategori ini',
'about.update.status.checking': 'Memeriksa...',
'about.update.status.available': 'v{version} tersedia',
'about.update.status.availableNoVersion': 'Pembaruan tersedia',
+2
View File
@@ -2709,6 +2709,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Riunioni',
'notifications.category.reminders': 'Promemoria',
'notifications.category.important': 'Importanti',
'notifications.filterAll': 'Tutte',
'notifications.filterEmpty': 'Nessun avviso in questa categoria',
'about.update.status.checking': 'Verifica in corso...',
'about.update.status.available': 'v{version} disponibile',
'about.update.status.availableNoVersion': 'Aggiornamento disponibile',
+2
View File
@@ -2641,6 +2641,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': '회의',
'notifications.category.reminders': '리마인더',
'notifications.category.important': '중요',
'notifications.filterAll': '전체',
'notifications.filterEmpty': '이 카테고리에 알림이 없습니다',
'about.update.status.checking': '확인 중...',
'about.update.status.available': 'v{version} 사용 가능',
'about.update.status.availableNoVersion': '업데이트 사용 가능',
+2
View File
@@ -2691,6 +2691,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Spotkania',
'notifications.category.reminders': 'Przypomnienia',
'notifications.category.important': 'Ważne',
'notifications.filterAll': 'Wszystkie',
'notifications.filterEmpty': 'Brak powiadomień w tej kategorii',
'about.update.status.checking': 'Sprawdzanie...',
'about.update.status.available': 'Dostępna wersja {version}',
'about.update.status.availableNoVersion': 'Dostępna aktualizacja',
+2
View File
@@ -2714,6 +2714,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Reuniões',
'notifications.category.reminders': 'Lembretes',
'notifications.category.important': 'Importante',
'notifications.filterAll': 'Todas',
'notifications.filterEmpty': 'Nenhum alerta nesta categoria',
'about.update.status.checking': 'Verificando...',
'about.update.status.available': 'v{version} disponível',
'about.update.status.availableNoVersion': 'Atualização disponível',
+2
View File
@@ -2687,6 +2687,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': 'Встречи',
'notifications.category.reminders': 'Напоминания',
'notifications.category.important': 'Важное',
'notifications.filterAll': 'Все',
'notifications.filterEmpty': 'Нет уведомлений в этой категории',
'about.update.status.checking': 'Проверка...',
'about.update.status.available': 'доступна v{version}',
'about.update.status.availableNoVersion': 'Доступно обновление',
+2
View File
@@ -2538,6 +2538,8 @@ const messages: TranslationMap = {
'notifications.category.meetings': '会议',
'notifications.category.reminders': '提醒',
'notifications.category.important': '重要',
'notifications.filterAll': '全部',
'notifications.filterEmpty': '此分类暂无通知',
'about.update.status.checking': '检查中...',
'about.update.status.available': 'v{version} 可用',
'about.update.status.availableNoVersion': '更新可用',
+80 -4
View File
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import NotificationBody from '../components/notifications/NotificationBody';
@@ -15,6 +15,19 @@ import {
selectUnreadCount,
} from '../store/notificationSlice';
// Canonical category order — drives the order chips appear in the filter row.
const CATEGORY_ORDER: NotificationCategory[] = [
'messages',
'agents',
'skills',
'system',
'meetings',
'reminders',
'important',
];
type CategoryFilter = NotificationCategory | 'all';
function formatTime(ts: number, t: (key: string) => string): string {
const delta = Date.now() - ts;
const min = Math.floor(delta / 60000);
@@ -32,6 +45,35 @@ const Notifications = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const unread = useMemo(() => selectUnreadCount(items), [items]);
const [selectedCategory, setSelectedCategory] = useState<CategoryFilter>('all');
// Only offer chips for categories that actually appear in the feed — no dead chips.
const presentCategories = useMemo(
() => CATEGORY_ORDER.filter(c => items.some(item => item.category === c)),
[items]
);
// If the active filter's category drains out of the feed, fall back to All.
const activeCategory: CategoryFilter =
selectedCategory !== 'all' && !presentCategories.includes(selectedCategory)
? 'all'
: selectedCategory;
// The derivation above keeps the current render correct, but the stored
// selection would otherwise stay stale — so if that category later reappears
// the filter would silently snap back to it. Reset the stored state to 'all'
// once a selected category leaves the feed so re-selection is always explicit.
useEffect(() => {
if (activeCategory !== selectedCategory) {
setSelectedCategory('all');
}
}, [activeCategory, selectedCategory]);
const filteredItems = useMemo(
() =>
activeCategory === 'all' ? items : items.filter(item => item.category === activeCategory),
[items, activeCategory]
);
const categoryLabel = (category: NotificationCategory): string => {
switch (category) {
@@ -97,13 +139,47 @@ const Notifications = () => {
</div>
</div>
{items.length === 0 ? (
{presentCategories.length > 0 && (
<div
data-testid="notification-category-filter"
className="flex flex-wrap items-center gap-2 border-b border-stone-100 dark:border-neutral-800 px-4 py-2">
<button
type="button"
data-testid="notif-filter-chip-all"
aria-pressed={activeCategory === 'all'}
onClick={() => setSelectedCategory('all')}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
activeCategory === 'all'
? 'bg-primary-500 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}>
{t('notifications.filterAll')}
</button>
{presentCategories.map(category => (
<button
key={category}
type="button"
data-testid={`notif-filter-chip-${category}`}
aria-pressed={activeCategory === category}
onClick={() => setSelectedCategory(category)}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
activeCategory === category
? 'bg-primary-500 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-700'
}`}>
{categoryLabel(category)}
</button>
))}
</div>
)}
{filteredItems.length === 0 ? (
<div className="px-6 py-16 text-center text-sm text-stone-500 dark:text-neutral-400">
{t('alerts.empty')}
{activeCategory === 'all' ? t('alerts.empty') : t('notifications.filterEmpty')}
</div>
) : (
<ul className="divide-y divide-stone-100 dark:divide-neutral-800">
{items.map(item => (
{filteredItems.map(item => (
<li key={item.id} data-testid="notification-item">
{/* `role="button"` instead of a real `<button>` — the row body
contains `NotificationLinkPill` (also a `<button>`), and
+96 -1
View File
@@ -10,14 +10,16 @@
* via the shared `NotificationBody` component.
*/
import { configureStore } from '@reduxjs/toolkit';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import notificationsReducer, {
clearAll,
type NotificationCategory,
type NotificationItem,
notificationReceived,
} from '../../store/notificationSlice';
import Notifications from '../Notifications';
@@ -139,3 +141,96 @@ describe('Notifications page row wrapper', () => {
expect(store.getState().notifications.items[0].read).toBe(false);
});
});
describe('Notifications page category filter', () => {
const mixed = () => [
makeItem('m-1', 'msg one', { category: 'messages' }),
makeItem('m-2', 'msg two', { category: 'messages' }),
makeItem('a-1', 'agent one', { category: 'agents' }),
makeItem('s-1', 'system one', { category: 'system' }),
];
it('renders an All chip plus one chip per category present in the feed', () => {
renderPage(mixed());
expect(screen.getByTestId('notif-filter-chip-all')).toBeInTheDocument();
expect(screen.getByTestId('notif-filter-chip-messages')).toBeInTheDocument();
expect(screen.getByTestId('notif-filter-chip-agents')).toBeInTheDocument();
expect(screen.getByTestId('notif-filter-chip-system')).toBeInTheDocument();
// No dead chips for categories absent from the feed.
expect(screen.queryByTestId('notif-filter-chip-skills')).not.toBeInTheDocument();
expect(screen.queryByTestId('notif-filter-chip-meetings')).not.toBeInTheDocument();
});
it('shows all items under the default All filter', () => {
renderPage(mixed());
expect(screen.getAllByTestId('notification-item')).toHaveLength(4);
expect(screen.getByTestId('notif-filter-chip-all')).toHaveAttribute('aria-pressed', 'true');
});
it('filters the list to the selected category and marks the chip active', () => {
renderPage(mixed());
fireEvent.click(screen.getByTestId('notif-filter-chip-messages'));
expect(screen.getAllByTestId('notification-item')).toHaveLength(2);
expect(screen.getByTestId('notif-filter-chip-messages')).toHaveAttribute(
'aria-pressed',
'true'
);
expect(screen.getByTestId('notif-filter-chip-all')).toHaveAttribute('aria-pressed', 'false');
});
it('restores the full list when All is reselected', () => {
renderPage(mixed());
fireEvent.click(screen.getByTestId('notif-filter-chip-agents'));
expect(screen.getAllByTestId('notification-item')).toHaveLength(1);
fireEvent.click(screen.getByTestId('notif-filter-chip-all'));
expect(screen.getAllByTestId('notification-item')).toHaveLength(4);
});
it('does not render the filter row when there are no notifications', () => {
renderPage([]);
expect(screen.queryByTestId('notification-category-filter')).not.toBeInTheDocument();
});
it('shows alerts.empty (not filterEmpty) when the feed is entirely empty', () => {
renderPage([]);
// t() mock returns the key; verify the generic empty key, not the category-filtered one.
// Two elements carry 'alerts.empty' (header subtext + empty-state body) — both correct.
expect(screen.getAllByText('alerts.empty').length).toBeGreaterThan(0);
expect(screen.queryByText('notifications.filterEmpty')).not.toBeInTheDocument();
});
it('falls back to All and forgets the selection when the active category drains, even if it reappears', () => {
const { store } = renderPage([makeItem('m-1', 'msg one', { category: 'messages' })]);
// Select the only category present.
fireEvent.click(screen.getByTestId('notif-filter-chip-messages'));
expect(screen.getByTestId('notif-filter-chip-messages')).toHaveAttribute(
'aria-pressed',
'true'
);
// Drain the feed — the selected category disappears, so the view falls back
// to All (the filter row goes away once nothing is present).
act(() => {
store.dispatch(clearAll());
});
expect(screen.queryByTestId('notification-category-filter')).not.toBeInTheDocument();
// The same category reappears. The stale 'messages' selection must NOT
// resurrect — the view stays on All and shows every item.
act(() => {
store.dispatch(notificationReceived(makeItem('m-2', 'msg two', { category: 'messages' })));
});
expect(screen.getByTestId('notif-filter-chip-all')).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('notif-filter-chip-messages')).toHaveAttribute(
'aria-pressed',
'false'
);
expect(screen.getAllByTestId('notification-item')).toHaveLength(1);
});
});