mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(notifications): filter alerts by category (#3718)
This commit is contained in:
@@ -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': 'تحديث متاح',
|
||||
|
||||
@@ -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': 'আপডেট পাওয়া গেছে',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'अपडेट उपलब्ध है',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '업데이트 사용 가능',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Доступно обновление',
|
||||
|
||||
@@ -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': '更新可用',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user