Skip to content

Commit d297367

Browse files
committed
feat(notification): add Zustand store for notification state management
- Create notification store with Zustand - Implement core state management (notifications, unread count, loading states) - Add actions: fetchNotifications, markAsRead, markAllAsRead, updateUnreadCount - Support tab filtering (all/changelog/message) and pagination - Add basic unit tests for store functionality - Type-safe implementation with full TypeScript support Related to notification system frontend implementation (PR #1 of 8)
1 parent 72e21d1 commit d297367

2 files changed

Lines changed: 326 additions & 0 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* Notification Store Tests
3+
* @description Basic tests for notification state management
4+
*/
5+
import type { NotificationWithReadStatus } from '@lib/types/notification-center';
6+
7+
import { useNotificationStore } from '../notification-store';
8+
9+
// Mock fetch API
10+
global.fetch = jest.fn();
11+
12+
describe('Notification Store', () => {
13+
beforeEach(() => {
14+
// Reset store and mocks
15+
useNotificationStore.getState().reset();
16+
jest.clearAllMocks();
17+
});
18+
19+
describe('Initial State', () => {
20+
it('should have correct initial state', () => {
21+
const store = useNotificationStore.getState();
22+
23+
expect(store.notifications).toEqual([]);
24+
expect(store.unreadCount).toEqual({ changelog: 0, message: 0, total: 0 });
25+
expect(store.isLoading).toBe(false);
26+
expect(store.error).toBeNull();
27+
expect(store.activeTab).toBe('all');
28+
expect(store.hasMore).toBe(false);
29+
expect(store.offset).toBe(0);
30+
});
31+
});
32+
33+
describe('State Management', () => {
34+
it('should reset store to initial state', () => {
35+
// Modify state
36+
const mockNotification: NotificationWithReadStatus = {
37+
id: '1',
38+
type: 'message',
39+
title: 'Test',
40+
content: 'Test content',
41+
priority: 'medium',
42+
target_roles: [],
43+
target_users: [],
44+
published: true,
45+
published_at: new Date().toISOString(),
46+
created_at: new Date().toISOString(),
47+
created_by: null,
48+
updated_at: new Date().toISOString(),
49+
metadata: {},
50+
is_read: false,
51+
read_at: null,
52+
};
53+
54+
useNotificationStore.setState({
55+
notifications: [mockNotification],
56+
isLoading: true,
57+
error: 'test error',
58+
activeTab: 'changelog',
59+
hasMore: true,
60+
offset: 10,
61+
});
62+
63+
// Reset
64+
useNotificationStore.getState().reset();
65+
66+
// Verify reset
67+
const store = useNotificationStore.getState();
68+
expect(store.notifications).toEqual([]);
69+
expect(store.isLoading).toBe(false);
70+
expect(store.error).toBeNull();
71+
expect(store.activeTab).toBe('all');
72+
expect(store.hasMore).toBe(false);
73+
expect(store.offset).toBe(0);
74+
});
75+
76+
it('should set active tab', () => {
77+
(global.fetch as jest.Mock).mockResolvedValueOnce({
78+
ok: true,
79+
json: async () => ({
80+
notifications: [],
81+
unread_count: { changelog: 0, message: 0, total: 0 },
82+
has_more: false,
83+
total_count: 0,
84+
}),
85+
});
86+
87+
useNotificationStore.getState().setActiveTab('message');
88+
89+
const store = useNotificationStore.getState();
90+
expect(store.activeTab).toBe('message');
91+
expect(store.offset).toBe(0);
92+
});
93+
});
94+
});

lib/stores/notification-store.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import type {
2+
GetNotificationsParams,
3+
NotificationTab,
4+
NotificationWithReadStatus,
5+
UnreadCount,
6+
} from '@lib/types/notification-center';
7+
import { create } from 'zustand';
8+
9+
/**
10+
* Notification state management store
11+
*
12+
* Manages the global state for the notification system including:
13+
* - Notification list with read status
14+
* - Unread count tracking
15+
* - Active tab selection
16+
* - Loading and error states
17+
*/
18+
interface NotificationState {
19+
/** List of notifications with read status */
20+
notifications: NotificationWithReadStatus[];
21+
/** Unread count by notification type */
22+
unreadCount: UnreadCount;
23+
/** Whether notifications are currently being fetched */
24+
isLoading: boolean;
25+
/** Error message if fetch failed */
26+
error: string | null;
27+
/** Currently active tab in notification center */
28+
activeTab: NotificationTab;
29+
/** Whether there are more notifications to load */
30+
hasMore: boolean;
31+
/** Current pagination offset */
32+
offset: number;
33+
34+
/**
35+
* Fetch notifications from the API
36+
*
37+
* @param params - Query parameters for filtering notifications
38+
* @param append - Whether to append to existing notifications or replace
39+
*/
40+
fetchNotifications: (
41+
params?: GetNotificationsParams,
42+
append?: boolean
43+
) => Promise<void>;
44+
45+
/**
46+
* Mark one or more notifications as read
47+
*
48+
* @param notificationIds - Array of notification IDs to mark as read
49+
*/
50+
markAsRead: (notificationIds: string[]) => Promise<void>;
51+
52+
/**
53+
* Mark all notifications as read for the current tab
54+
*/
55+
markAllAsRead: () => Promise<void>;
56+
57+
/**
58+
* Update unread count from the API
59+
*/
60+
updateUnreadCount: () => Promise<void>;
61+
62+
/**
63+
* Set the active tab and fetch corresponding notifications
64+
*
65+
* @param tab - Tab to activate
66+
*/
67+
setActiveTab: (tab: NotificationTab) => void;
68+
69+
/**
70+
* Reset the notification store to initial state
71+
*/
72+
reset: () => void;
73+
74+
/**
75+
* Load more notifications (pagination)
76+
*/
77+
loadMore: () => Promise<void>;
78+
}
79+
80+
const INITIAL_STATE = {
81+
notifications: [],
82+
unreadCount: { changelog: 0, message: 0, total: 0 },
83+
isLoading: false,
84+
error: null,
85+
activeTab: 'all' as NotificationTab,
86+
hasMore: false,
87+
offset: 0,
88+
};
89+
90+
/**
91+
* Global notification store using Zustand
92+
*/
93+
export const useNotificationStore = create<NotificationState>((set, get) => ({
94+
...INITIAL_STATE,
95+
96+
fetchNotifications: async (params = {}, append = false) => {
97+
set({ isLoading: true, error: null });
98+
99+
try {
100+
const { activeTab, offset: currentOffset } = get();
101+
102+
// Build query parameters based on active tab
103+
const queryParams: GetNotificationsParams = {
104+
...params,
105+
offset: append ? currentOffset : 0,
106+
limit: params.limit || 20,
107+
};
108+
109+
// Filter by type if not on 'all' tab
110+
if (activeTab !== 'all') {
111+
queryParams.type = activeTab;
112+
}
113+
114+
const searchParams = new URLSearchParams();
115+
Object.entries(queryParams).forEach(([key, value]) => {
116+
if (value !== undefined && value !== null) {
117+
searchParams.append(key, String(value));
118+
}
119+
});
120+
121+
const response = await fetch(
122+
`/api/notifications?${searchParams.toString()}`
123+
);
124+
125+
if (!response.ok) {
126+
throw new Error('Failed to fetch notifications');
127+
}
128+
129+
const data = await response.json();
130+
131+
set(state => ({
132+
notifications: append
133+
? [...state.notifications, ...data.notifications]
134+
: data.notifications,
135+
unreadCount: data.unread_count,
136+
hasMore: data.has_more,
137+
offset: append
138+
? state.offset + data.notifications.length
139+
: data.notifications.length,
140+
isLoading: false,
141+
}));
142+
} catch (error) {
143+
set({
144+
error:
145+
error instanceof Error ? error.message : 'Unknown error occurred',
146+
isLoading: false,
147+
});
148+
}
149+
},
150+
151+
markAsRead: async notificationIds => {
152+
try {
153+
const response = await fetch('/api/notifications/mark-read', {
154+
method: 'POST',
155+
headers: { 'Content-Type': 'application/json' },
156+
body: JSON.stringify({ notification_ids: notificationIds }),
157+
});
158+
159+
if (!response.ok) {
160+
throw new Error('Failed to mark notifications as read');
161+
}
162+
163+
// Update local state optimistically
164+
set(state => ({
165+
notifications: state.notifications.map(notification =>
166+
notificationIds.includes(notification.id)
167+
? {
168+
...notification,
169+
is_read: true,
170+
read_at: new Date().toISOString(),
171+
}
172+
: notification
173+
),
174+
}));
175+
176+
// Refresh unread count
177+
await get().updateUnreadCount();
178+
} catch (error) {
179+
set({
180+
error:
181+
error instanceof Error ? error.message : 'Failed to mark as read',
182+
});
183+
}
184+
},
185+
186+
markAllAsRead: async () => {
187+
const { notifications, activeTab } = get();
188+
189+
// Filter unread notifications based on active tab
190+
const unreadNotifications = notifications.filter(n => {
191+
if (activeTab === 'all') return !n.is_read;
192+
return !n.is_read && n.type === activeTab;
193+
});
194+
195+
if (unreadNotifications.length === 0) return;
196+
197+
const notificationIds = unreadNotifications.map(n => n.id);
198+
await get().markAsRead(notificationIds);
199+
},
200+
201+
updateUnreadCount: async () => {
202+
try {
203+
const response = await fetch('/api/notifications/unread-count');
204+
205+
if (!response.ok) {
206+
throw new Error('Failed to fetch unread count');
207+
}
208+
209+
const data = await response.json();
210+
set({ unreadCount: data.unread_count });
211+
} catch (error) {
212+
console.error('Failed to update unread count:', error);
213+
}
214+
},
215+
216+
setActiveTab: tab => {
217+
set({ activeTab: tab, offset: 0 });
218+
// Fetch notifications for the new tab
219+
get().fetchNotifications();
220+
},
221+
222+
loadMore: async () => {
223+
const { hasMore, isLoading } = get();
224+
if (!hasMore || isLoading) return;
225+
226+
await get().fetchNotifications({}, true);
227+
},
228+
229+
reset: () => {
230+
set(INITIAL_STATE);
231+
},
232+
}));

0 commit comments

Comments
 (0)