From 9c70ca6245a717effe8ecd9cc79164ca5ae179d1 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 2 Oct 2026 11:08:01 -0700 Subject: [PATCH] fix(notifications): parse weekly rotation and show loading errors --- .../fix-weekly-rotation-notifications.md | 5 + packages/common/src/adapters/notification.ts | 18 +- .../default/apis/NotificationsApi.ts | 1 + .../generated/default/models/Notification.ts | 13 +- .../models/WeeklyRotationNotification.ts | 107 ++++++++++++ .../WeeklyRotationNotificationAction.ts | 99 +++++++++++ .../WeeklyRotationNotificationActionData.ts | 74 +++++++++ .../sdk/api/generated/default/models/index.ts | 3 + .../NotificationsResponse.test.ts | 111 +++++++++++++ .../notification/NotificationPanel.test.tsx | 154 ++++++++++++++++++ .../notification/NotificationPanel.tsx | 46 ++++-- 11 files changed, 606 insertions(+), 25 deletions(-) create mode 100644 .changeset/fix-weekly-rotation-notifications.md create mode 100644 packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotification.ts create mode 100644 packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationAction.ts create mode 100644 packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationActionData.ts create mode 100644 packages/sdk/src/sdk/api/notifications/NotificationsResponse.test.ts create mode 100644 packages/web/src/components/notification/NotificationPanel.test.tsx diff --git a/.changeset/fix-weekly-rotation-notifications.md b/.changeset/fix-weekly-rotation-notifications.md new file mode 100644 index 00000000000..2876ca4f200 --- /dev/null +++ b/.changeset/fix-weekly-rotation-notifications.md @@ -0,0 +1,5 @@ +--- +'@audius/sdk': patch +--- + +Parse weekly rotation notifications so a response containing one does not fail to load the entire notification list. diff --git a/packages/common/src/adapters/notification.ts b/packages/common/src/adapters/notification.ts index 101ea49d2ea..69da6861eb7 100644 --- a/packages/common/src/adapters/notification.ts +++ b/packages/common/src/adapters/notification.ts @@ -600,6 +600,15 @@ export const notificationFromSDK = ( ...formatBaseNotification(notification) } } + case 'weekly_rotation': { + const data = notification.actions[0].data + return { + type: NotificationType.WeeklyRotation, + year: data.year, + week: data.week, + ...formatBaseNotification(notification) + } + } case 'fan_remix_contest_ended': { const data = notification.actions[0].data return { @@ -688,15 +697,6 @@ export const notificationFromSDK = ( type: string actions: typeof notification.actions } - if (n.type === 'weekly_rotation') { - const data = n.actions[0].data as unknown as Record - return { - type: NotificationType.WeeklyRotation, - year: data.year, - week: data.week, - ...formatBaseNotification(notification) - } - } if (n.type === 'remix_contest_update') { const data = n.actions[0].data as unknown as Record return { diff --git a/packages/sdk/src/sdk/api/generated/default/apis/NotificationsApi.ts b/packages/sdk/src/sdk/api/generated/default/apis/NotificationsApi.ts index 4ae8c3737aa..476aa0cacb0 100644 --- a/packages/sdk/src/sdk/api/generated/default/apis/NotificationsApi.ts +++ b/packages/sdk/src/sdk/api/generated/default/apis/NotificationsApi.ts @@ -174,6 +174,7 @@ export const GetNotificationsTypesEnum = { CommentMention: 'comment_mention', CommentReaction: 'comment_reaction', ListenStreakReminder: 'listen_streak_reminder', + WeeklyRotation: 'weekly_rotation', FanRemixContestStarted: 'fan_remix_contest_started', FanRemixContestEnded: 'fan_remix_contest_ended', FanRemixContestEndingSoon: 'fan_remix_contest_ending_soon', diff --git a/packages/sdk/src/sdk/api/generated/default/models/Notification.ts b/packages/sdk/src/sdk/api/generated/default/models/Notification.ts index 0108684de82..6682804d8d7 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/Notification.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/Notification.ts @@ -320,13 +320,20 @@ import { UsdcPurchaseSellerNotificationFromJSONTyped, UsdcPurchaseSellerNotificationToJSON, } from './UsdcPurchaseSellerNotification'; +import { + WeeklyRotationNotification, + instanceOfWeeklyRotationNotification, + WeeklyRotationNotificationFromJSON, + WeeklyRotationNotificationFromJSONTyped, + WeeklyRotationNotificationToJSON, +} from './WeeklyRotationNotification'; /** * @type Notification * * @export */ -export type Notification = { type: 'announcement' } & AnnouncementNotification | { type: 'approve_manager_request' } & ApproveManagerRequestNotification | { type: 'artist_remix_contest_ended' } & ArtistRemixContestEndedNotification | { type: 'artist_remix_contest_ending_soon' } & ArtistRemixContestEndingSoonNotification | { type: 'artist_remix_contest_submissions' } & ArtistRemixContestSubmissionsNotification | { type: 'challenge_reward' } & ChallengeRewardNotification | { type: 'claimable_reward' } & ClaimableRewardNotification | { type: 'comment' } & CommentNotification | { type: 'comment_mention' } & CommentMentionNotification | { type: 'comment_reaction' } & CommentReactionNotification | { type: 'comment_thread' } & CommentThreadNotification | { type: 'cosign' } & CosignNotification | { type: 'create' } & CreateNotification | { type: 'fan_club_text_post' } & FanClubTextPostNotification | { type: 'fan_remix_contest_ended' } & FanRemixContestEndedNotification | { type: 'fan_remix_contest_ending_soon' } & FanRemixContestEndingSoonNotification | { type: 'fan_remix_contest_started' } & FanRemixContestStartedNotification | { type: 'fan_remix_contest_submission' } & FanRemixContestSubmissionNotification | { type: 'fan_remix_contest_winners_selected' } & FanRemixContestWinnersSelectedNotification | { type: 'follow' } & FollowNotification | { type: 'listen_streak_reminder' } & ListenStreakReminderNotification | { type: 'milestone' } & MilestoneNotification | { type: 'reaction' } & ReactionNotification | { type: 'remix' } & RemixNotification | { type: 'remix_contest_update' } & RemixContestUpdateNotification | { type: 'repost' } & RepostNotification | { type: 'repost_of_repost' } & RepostOfRepostNotification | { type: 'request_manager' } & RequestManagerNotification | { type: 'save' } & SaveNotification | { type: 'save_of_repost' } & SaveOfRepostNotification | { type: 'supporter_dethroned' } & SupporterDethronedNotification | { type: 'supporter_rank_up' } & SupporterRankUpNotification | { type: 'supporting_rank_up' } & SupporterRankUpNotification | { type: 'tastemaker' } & TastemakerNotification | { type: 'tier_change' } & TierChangeNotification | { type: 'tip_receive' } & ReceiveTipNotification | { type: 'tip_send' } & SendTipNotification | { type: 'track_added_to_playlist' } & TrackAddedToPlaylistNotification | { type: 'track_added_to_purchased_album' } & TrackAddedToPurchasedAlbumNotification | { type: 'track_collaborator_accept' } & TrackCollaboratorNotification | { type: 'track_collaborator_invite' } & TrackCollaboratorNotification | { type: 'trending' } & TrendingNotification | { type: 'trending_playlist' } & TrendingPlaylistNotification | { type: 'trending_underground' } & TrendingUndergroundNotification | { type: 'usdc_purchase_buyer' } & UsdcPurchaseBuyerNotification | { type: 'usdc_purchase_seller' } & UsdcPurchaseSellerNotification; +export type Notification = { type: 'announcement' } & AnnouncementNotification | { type: 'approve_manager_request' } & ApproveManagerRequestNotification | { type: 'artist_remix_contest_ended' } & ArtistRemixContestEndedNotification | { type: 'artist_remix_contest_ending_soon' } & ArtistRemixContestEndingSoonNotification | { type: 'artist_remix_contest_submissions' } & ArtistRemixContestSubmissionsNotification | { type: 'challenge_reward' } & ChallengeRewardNotification | { type: 'claimable_reward' } & ClaimableRewardNotification | { type: 'comment' } & CommentNotification | { type: 'comment_mention' } & CommentMentionNotification | { type: 'comment_reaction' } & CommentReactionNotification | { type: 'comment_thread' } & CommentThreadNotification | { type: 'cosign' } & CosignNotification | { type: 'create' } & CreateNotification | { type: 'fan_club_text_post' } & FanClubTextPostNotification | { type: 'fan_remix_contest_ended' } & FanRemixContestEndedNotification | { type: 'fan_remix_contest_ending_soon' } & FanRemixContestEndingSoonNotification | { type: 'fan_remix_contest_started' } & FanRemixContestStartedNotification | { type: 'fan_remix_contest_submission' } & FanRemixContestSubmissionNotification | { type: 'fan_remix_contest_winners_selected' } & FanRemixContestWinnersSelectedNotification | { type: 'follow' } & FollowNotification | { type: 'listen_streak_reminder' } & ListenStreakReminderNotification | { type: 'milestone' } & MilestoneNotification | { type: 'reaction' } & ReactionNotification | { type: 'remix' } & RemixNotification | { type: 'remix_contest_update' } & RemixContestUpdateNotification | { type: 'repost' } & RepostNotification | { type: 'repost_of_repost' } & RepostOfRepostNotification | { type: 'request_manager' } & RequestManagerNotification | { type: 'save' } & SaveNotification | { type: 'save_of_repost' } & SaveOfRepostNotification | { type: 'supporter_dethroned' } & SupporterDethronedNotification | { type: 'supporter_rank_up' } & SupporterRankUpNotification | { type: 'supporting_rank_up' } & SupporterRankUpNotification | { type: 'tastemaker' } & TastemakerNotification | { type: 'tier_change' } & TierChangeNotification | { type: 'tip_receive' } & ReceiveTipNotification | { type: 'tip_send' } & SendTipNotification | { type: 'track_added_to_playlist' } & TrackAddedToPlaylistNotification | { type: 'track_added_to_purchased_album' } & TrackAddedToPurchasedAlbumNotification | { type: 'track_collaborator_accept' } & TrackCollaboratorNotification | { type: 'track_collaborator_invite' } & TrackCollaboratorNotification | { type: 'trending' } & TrendingNotification | { type: 'trending_playlist' } & TrendingPlaylistNotification | { type: 'trending_underground' } & TrendingUndergroundNotification | { type: 'usdc_purchase_buyer' } & UsdcPurchaseBuyerNotification | { type: 'usdc_purchase_seller' } & UsdcPurchaseSellerNotification | { type: 'weekly_rotation' } & WeeklyRotationNotification; export function NotificationFromJSON(json: any): Notification { return NotificationFromJSONTyped(json, false); @@ -429,6 +436,8 @@ export function NotificationFromJSONTyped(json: any, ignoreDiscriminator: boolea return {...UsdcPurchaseBuyerNotificationFromJSONTyped(json, true), type: 'usdc_purchase_buyer'}; case 'usdc_purchase_seller': return {...UsdcPurchaseSellerNotificationFromJSONTyped(json, true), type: 'usdc_purchase_seller'}; + case 'weekly_rotation': + return {...WeeklyRotationNotificationFromJSONTyped(json, true), type: 'weekly_rotation'}; default: throw new Error(`No variant of Notification exists with 'type=${json['type']}'`); } @@ -534,6 +543,8 @@ export function NotificationToJSON(value?: Notification | null): any { return UsdcPurchaseBuyerNotificationToJSON(value); case 'usdc_purchase_seller': return UsdcPurchaseSellerNotificationToJSON(value); + case 'weekly_rotation': + return WeeklyRotationNotificationToJSON(value); default: throw new Error(`No variant of Notification exists with 'type=${value['type']}'`); } diff --git a/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotification.ts b/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotification.ts new file mode 100644 index 00000000000..fb4a87a4f3e --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotification.ts @@ -0,0 +1,107 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { WeeklyRotationNotificationAction } from './WeeklyRotationNotificationAction'; +import { + WeeklyRotationNotificationActionFromJSON, + WeeklyRotationNotificationActionFromJSONTyped, + WeeklyRotationNotificationActionToJSON, +} from './WeeklyRotationNotificationAction'; + +/** + * + * @export + * @interface WeeklyRotationNotification + */ +export interface WeeklyRotationNotification { + /** + * + * @type {string} + * @memberof WeeklyRotationNotification + */ + type: string; + /** + * + * @type {string} + * @memberof WeeklyRotationNotification + */ + groupId: string; + /** + * + * @type {boolean} + * @memberof WeeklyRotationNotification + */ + isSeen: boolean; + /** + * + * @type {number} + * @memberof WeeklyRotationNotification + */ + seenAt?: number; + /** + * + * @type {Array} + * @memberof WeeklyRotationNotification + */ + actions: Array; +} + +/** + * Check if a given object implements the WeeklyRotationNotification interface. + */ +export function instanceOfWeeklyRotationNotification(value: object): value is WeeklyRotationNotification { + let isInstance = true; + isInstance = isInstance && "type" in value && value["type"] !== undefined; + isInstance = isInstance && "groupId" in value && value["groupId"] !== undefined; + isInstance = isInstance && "isSeen" in value && value["isSeen"] !== undefined; + isInstance = isInstance && "actions" in value && value["actions"] !== undefined; + + return isInstance; +} + +export function WeeklyRotationNotificationFromJSON(json: any): WeeklyRotationNotification { + return WeeklyRotationNotificationFromJSONTyped(json, false); +} + +export function WeeklyRotationNotificationFromJSONTyped(json: any, ignoreDiscriminator: boolean): WeeklyRotationNotification { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'type': json['type'], + 'groupId': json['group_id'], + 'isSeen': json['is_seen'], + 'seenAt': !exists(json, 'seen_at') ? undefined : json['seen_at'], + 'actions': ((json['actions'] as Array).map(WeeklyRotationNotificationActionFromJSON)), + }; +} + +export function WeeklyRotationNotificationToJSON(value?: WeeklyRotationNotification | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'type': value.type, + 'group_id': value.groupId, + 'is_seen': value.isSeen, + 'seen_at': value.seenAt, + 'actions': ((value.actions as Array).map(WeeklyRotationNotificationActionToJSON)), + }; +} diff --git a/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationAction.ts b/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationAction.ts new file mode 100644 index 00000000000..8ec452e959d --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationAction.ts @@ -0,0 +1,99 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +import type { WeeklyRotationNotificationActionData } from './WeeklyRotationNotificationActionData'; +import { + WeeklyRotationNotificationActionDataFromJSON, + WeeklyRotationNotificationActionDataFromJSONTyped, + WeeklyRotationNotificationActionDataToJSON, +} from './WeeklyRotationNotificationActionData'; + +/** + * + * @export + * @interface WeeklyRotationNotificationAction + */ +export interface WeeklyRotationNotificationAction { + /** + * + * @type {string} + * @memberof WeeklyRotationNotificationAction + */ + specifier: string; + /** + * + * @type {string} + * @memberof WeeklyRotationNotificationAction + */ + type: string; + /** + * + * @type {number} + * @memberof WeeklyRotationNotificationAction + */ + timestamp: number; + /** + * + * @type {WeeklyRotationNotificationActionData} + * @memberof WeeklyRotationNotificationAction + */ + data: WeeklyRotationNotificationActionData; +} + +/** + * Check if a given object implements the WeeklyRotationNotificationAction interface. + */ +export function instanceOfWeeklyRotationNotificationAction(value: object): value is WeeklyRotationNotificationAction { + let isInstance = true; + isInstance = isInstance && "specifier" in value && value["specifier"] !== undefined; + isInstance = isInstance && "type" in value && value["type"] !== undefined; + isInstance = isInstance && "timestamp" in value && value["timestamp"] !== undefined; + isInstance = isInstance && "data" in value && value["data"] !== undefined; + + return isInstance; +} + +export function WeeklyRotationNotificationActionFromJSON(json: any): WeeklyRotationNotificationAction { + return WeeklyRotationNotificationActionFromJSONTyped(json, false); +} + +export function WeeklyRotationNotificationActionFromJSONTyped(json: any, ignoreDiscriminator: boolean): WeeklyRotationNotificationAction { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'specifier': json['specifier'], + 'type': json['type'], + 'timestamp': json['timestamp'], + 'data': WeeklyRotationNotificationActionDataFromJSON(json['data']), + }; +} + +export function WeeklyRotationNotificationActionToJSON(value?: WeeklyRotationNotificationAction | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'specifier': value.specifier, + 'type': value.type, + 'timestamp': value.timestamp, + 'data': WeeklyRotationNotificationActionDataToJSON(value.data), + }; +} diff --git a/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationActionData.ts b/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationActionData.ts new file mode 100644 index 00000000000..037e90c5437 --- /dev/null +++ b/packages/sdk/src/sdk/api/generated/default/models/WeeklyRotationNotificationActionData.ts @@ -0,0 +1,74 @@ +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck +/** + * Audius API + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { exists, mapValues } from '../runtime'; +/** + * The rotation period being announced, as an ISO (year, week) pair; the mix itself is served by GET /v1/users/{id}/weekly-rotation. + * @export + * @interface WeeklyRotationNotificationActionData + */ +export interface WeeklyRotationNotificationActionData { + /** + * + * @type {number} + * @memberof WeeklyRotationNotificationActionData + */ + year: number; + /** + * + * @type {number} + * @memberof WeeklyRotationNotificationActionData + */ + week: number; +} + +/** + * Check if a given object implements the WeeklyRotationNotificationActionData interface. + */ +export function instanceOfWeeklyRotationNotificationActionData(value: object): value is WeeklyRotationNotificationActionData { + let isInstance = true; + isInstance = isInstance && "year" in value && value["year"] !== undefined; + isInstance = isInstance && "week" in value && value["week"] !== undefined; + + return isInstance; +} + +export function WeeklyRotationNotificationActionDataFromJSON(json: any): WeeklyRotationNotificationActionData { + return WeeklyRotationNotificationActionDataFromJSONTyped(json, false); +} + +export function WeeklyRotationNotificationActionDataFromJSONTyped(json: any, ignoreDiscriminator: boolean): WeeklyRotationNotificationActionData { + if ((json === undefined) || (json === null)) { + return json; + } + return { + + 'year': json['year'], + 'week': json['week'], + }; +} + +export function WeeklyRotationNotificationActionDataToJSON(value?: WeeklyRotationNotificationActionData | null): any { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + return { + + 'year': value.year, + 'week': value.week, + }; +} diff --git a/packages/sdk/src/sdk/api/generated/default/models/index.ts b/packages/sdk/src/sdk/api/generated/default/models/index.ts index aeff13f34c5..cb8cbe45dbe 100644 --- a/packages/sdk/src/sdk/api/generated/default/models/index.ts +++ b/packages/sdk/src/sdk/api/generated/default/models/index.ts @@ -417,4 +417,7 @@ export * from './UserTracksDownloadCountResponse'; export * from './UserTracksRemixedResponse'; export * from './VerifyToken'; export * from './VersionMetadata'; +export * from './WeeklyRotationNotification'; +export * from './WeeklyRotationNotificationAction'; +export * from './WeeklyRotationNotificationActionData'; export * from './WriteResponse'; diff --git a/packages/sdk/src/sdk/api/notifications/NotificationsResponse.test.ts b/packages/sdk/src/sdk/api/notifications/NotificationsResponse.test.ts new file mode 100644 index 00000000000..1fd96e46626 --- /dev/null +++ b/packages/sdk/src/sdk/api/notifications/NotificationsResponse.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + NotificationsApi, + GetNotificationsTypesEnum +} from '../generated/default/apis/NotificationsApi' +import { + NotificationFromJSON, + NotificationToJSON +} from '../generated/default/models/Notification' +import { Configuration } from '../generated/default/runtime' + +const weeklyRotation = { + type: 'weekly_rotation', + group_id: 'weekly_rotation:2026:40', + is_seen: false, + actions: [ + { + specifier: '7eP5n', + type: 'weekly_rotation', + timestamp: 1790769600, + data: { year: 2026, week: 40 } + } + ] +} + +describe('notifications response parsing', () => { + it('loads a page containing weekly rotation and existing notification types', async () => { + const fetchApi = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + unread_count: 2, + notifications: [ + weeklyRotation, + { + type: 'follow', + group_id: 'follow:7eP5n', + is_seen: true, + seen_at: 1790769601, + actions: [ + { + specifier: 'x5pJ3Aj', + type: 'follow', + timestamp: 1790769500, + data: { + follower_user_id: 'x5pJ3Aj', + followee_user_id: '7eP5n' + } + } + ] + } + ] + }, + related: { users: [], tracks: [], playlists: [] } + }) + ) + ) + // The SDK wrapper inherits this read method from the generated API. + const api = new NotificationsApi( + new Configuration({ basePath: 'https://example.com/v1', fetchApi }) + ) + + const response = await api.getNotifications({ + id: '7eP5n', + userId: '7eP5n', + limit: 20 + }) + + expect(response.data?.unreadCount).toBe(2) + expect(response.data?.notifications).toEqual([ + { + type: 'weekly_rotation', + groupId: weeklyRotation.group_id, + isSeen: false, + seenAt: undefined, + actions: weeklyRotation.actions + }, + { + type: 'follow', + groupId: 'follow:7eP5n', + isSeen: true, + seenAt: 1790769601, + actions: [ + { + specifier: 'x5pJ3Aj', + type: 'follow', + timestamp: 1790769500, + data: { followerUserId: 'x5pJ3Aj', followeeUserId: '7eP5n' } + } + ] + } + ]) + expect(response.related).toEqual({ users: [], tracks: [], playlists: [] }) + }) + + it('round trips a viewed weekly rotation notification', () => { + const notification = { + ...weeklyRotation, + is_seen: true, + seen_at: 1790769700 + } + expect(NotificationToJSON(NotificationFromJSON(notification))).toEqual( + notification + ) + }) + + it('supports filtering for weekly rotation notifications', () => { + expect(GetNotificationsTypesEnum.WeeklyRotation).toBe('weekly_rotation') + }) +}) diff --git a/packages/web/src/components/notification/NotificationPanel.test.tsx b/packages/web/src/components/notification/NotificationPanel.test.tsx new file mode 100644 index 00000000000..cb32ef48421 --- /dev/null +++ b/packages/web/src/components/notification/NotificationPanel.test.tsx @@ -0,0 +1,154 @@ +import { createRef, type ReactNode } from 'react' + +import { useNotifications } from '@audius/common/api' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { NotificationPanel } from './NotificationPanel' + +vi.mock('@audius/common/api', () => ({ + useNotifications: vi.fn(), + useMarkNotificationsAsViewed: () => ({ mutate: vi.fn() }) +})) + +vi.mock('react-redux', () => ({ useSelector: () => false })) + +vi.mock('@audius/harmony', () => ({ + Popup: ({ children }: { children: ReactNode }) =>
{children}
, + Scrollbar: ({ children }: { children: ReactNode }) =>
{children}
, + Flex: ({ children, role }: { children: ReactNode; role?: string }) => ( +
{children}
+ ), + Text: ({ children }: { children: ReactNode }) => {children}, + Button: (props: React.ComponentProps<'button'>) => + + ) +})) + +vi.mock('./Notification', () => ({ + Notification: () =>
  • Existing notification
  • +})) + +vi.mock('store/application/ui/userListModal/selectors', () => ({ + getIsOpen: () => false +})) + +const query = { + notifications: [], + fetchNextPage: vi.fn(), + refetch: vi.fn(), + hasNextPage: false, + isPending: false, + isError: false, + isFetching: false, + isFetchingNextPage: false +} + +const renderPanel = (overrides: Record = {}) => { + vi.mocked(useNotifications).mockReturnValue({ ...query, ...overrides } as any) + return render( + ()} + isOpen + onClose={vi.fn()} + /> + ) +} + +describe('NotificationPanel', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(cleanup) + + it('shows a spinner before the first page has loaded', () => { + renderPanel({ isPending: true, isFetching: true, hasNextPage: undefined }) + + expect( + screen.getByRole('progressbar', { name: 'Loading notifications' }) + ).toBeTruthy() + expect(screen.queryByText('There’s Nothing Here Yet!')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Load more' })) + expect(query.fetchNextPage).not.toHaveBeenCalled() + }) + + it('shows an error and retries a failed initial request', () => { + renderPanel({ isError: true }) + + expect(screen.getByRole('alert').textContent).toContain( + 'Unable to load notifications' + ) + expect(screen.queryByText('There’s Nothing Here Yet!')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Try Again' })) + expect(query.refetch).toHaveBeenCalledOnce() + }) + + it('shows the empty state only after a successful empty response', () => { + renderPanel() + + expect(screen.getByText('There’s Nothing Here Yet!')).toBeTruthy() + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('progressbar')).toBeNull() + }) + + it('keeps loaded notifications visible when a later request fails', () => { + renderPanel({ + notifications: [{ id: 'existing' }], + isError: true, + isFetching: true, + hasNextPage: true + }) + + expect(screen.getByText('Existing notification')).toBeTruthy() + expect( + screen.getByRole('button', { name: 'Try Again' }) + .disabled + ).toBe(true) + fireEvent.click(screen.getByRole('button', { name: 'Load more' })) + expect(query.fetchNextPage).not.toHaveBeenCalled() + }) + + it('loads more only when there is another page and no request is in flight', () => { + renderPanel({ notifications: [{ id: 'existing' }], hasNextPage: true }) + fireEvent.click(screen.getByRole('button', { name: 'Load more' })) + expect(query.fetchNextPage).toHaveBeenCalledOnce() + }) + + it('shows pagination loading and prevents overlapping requests', () => { + renderPanel({ + notifications: [{ id: 'existing' }], + hasNextPage: true, + isFetching: true, + isFetchingNextPage: true + }) + + expect(screen.getByText('Existing notification')).toBeTruthy() + expect(screen.getByRole('progressbar')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Load more' })) + expect(query.fetchNextPage).not.toHaveBeenCalled() + }) +}) diff --git a/packages/web/src/components/notification/NotificationPanel.tsx b/packages/web/src/components/notification/NotificationPanel.tsx index 526db473e2c..ecda3715ef1 100644 --- a/packages/web/src/components/notification/NotificationPanel.tsx +++ b/packages/web/src/components/notification/NotificationPanel.tsx @@ -11,6 +11,7 @@ import { Popup, Flex, Text, + Button, LoadingSpinner, useTheme } from '@audius/harmony' @@ -24,7 +25,10 @@ import { EmptyNotifications } from './EmptyNotifications' import { Notification } from './Notification' const messages = { - title: 'Notifications' + title: 'Notifications', + loading: 'Loading notifications', + error: 'Unable to load notifications. Please try again.', + retry: 'Try Again' } type NotificationPanelProps = { @@ -59,14 +63,15 @@ export const NotificationPanel = ({ hasNextPage, isPending, isError, - isFetchingNextPage + isFetching, + refetch } = useNotifications() const handleLoadMore = useCallback(() => { - if (!isFetchingNextPage) { + if (hasNextPage && !isFetching && !isError) { fetchNextPage() } - }, [fetchNextPage, isFetchingNextPage]) + }, [fetchNextPage, hasNextPage, isFetching, isError]) const isUserListOpen = useSelector(getIsUserListOpen) const { mutate: markAsViewed } = useMarkNotificationsAsViewed() @@ -94,7 +99,7 @@ export const NotificationPanel = ({ }, [isOpen, markAsViewed]) const userHasNoNotifications = - (!isPending || isError) && notifications.length === 0 + !isPending && !isError && notifications.length === 0 return ( - } getScrollParent={getScrollParent} css={{ display: 'flex', @@ -174,6 +171,25 @@ export const NotificationPanel = ({ ) }) )} + {isError ? ( + + {messages.error} + + + ) : isPending || isFetching ? ( + + + + ) : null}