diff --git a/src/renderer/components/notifications/NotificationList.test.tsx b/src/renderer/components/notifications/NotificationList.test.tsx new file mode 100644 index 000000000..3be2cc201 --- /dev/null +++ b/src/renderer/components/notifications/NotificationList.test.tsx @@ -0,0 +1,338 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; + +import { renderWithProviders } from '../../__helpers__/test-utils'; +import { + mockGitHubCloudAccount, + mockGitHubEnterpriseServerAccount, +} from '../../__mocks__/account-mocks'; +import { + mockGitifyNotification, + mockGithubEnterpriseGitifyNotifications, + mockMultipleAccountNotifications, +} from '../../__mocks__/notifications-mocks'; + +import { GroupBy } from '../../types'; + +import { getAccountUUID } from '../../utils/auth/utils'; +import { buildAccountItems, buildRepositoryItems, NotificationList } from './NotificationList'; + +// LegendList virtualizes based on real scroll metrics, which happy-dom cannot +// provide. Renders every flattened item through the real renderItem callback so +// the component's useMemo flattening still runs and can be asserted on. +vi.mock('@legendapp/list/react', () => ({ + LegendList: ({ + data, + renderItem, + }: { + data: unknown[]; + renderItem: (info: { item: unknown }) => ReactElement; + }) =>
{data.map((item) => renderItem({ item }))}
, +})); + +type BuildItemsContext = Parameters[1]; + +function makeContext(overrides: Partial = {}): BuildItemsContext { + return { + showAccountHeader: false, + hasMultipleAccounts: false, + collapsedAccounts: new Set(), + collapsedRepositories: new Set(), + animatingNotifications: new Set(), + animatingRepositories: new Map>(), + groupBy: GroupBy.REPOSITORY, + ...overrides, + }; +} + +function notification(id: string, repoFullName: string, order: number) { + return { + ...mockGitifyNotification, + id, + order, + repository: { + ...(mockGitifyNotification.repository as NonNullable< + typeof mockGitifyNotification.repository + >), + fullName: repoFullName, + }, + }; +} + +describe('renderer/components/notifications/NotificationList.tsx', () => { + describe('buildRepositoryItems', () => { + const accountUUID = getAccountUUID(mockGitHubCloudAccount); + + it('flattens repositories in first-seen order with their expanded notifications', () => { + const repoA1 = notification('a-1', 'owner/repo-a', 0); + const repoB1 = notification('b-1', 'owner/repo-b', 1); + const repoA2 = notification('a-2', 'owner/repo-a', 2); + + const items = buildRepositoryItems(accountUUID, [repoA1, repoB1, repoA2], makeContext()); + + expect(items.map((item) => item.kind)).toEqual([ + 'repository', + 'notification', + 'notification', + 'repository', + 'notification', + ]); + expect(items.map((item) => item.key)).toEqual([ + `repository-${accountUUID}-owner/repo-a`, + `notification-${accountUUID}:a-1`, + `notification-${accountUUID}:a-2`, + `repository-${accountUUID}-owner/repo-b`, + `notification-${accountUUID}:b-1`, + ]); + + const [repoA, , , repoB] = items; + expect(repoA).toMatchObject({ + kind: 'repository', + repoKey: `${accountUUID}-owner/repo-a`, + repoName: 'owner/repo-a', + notifications: [repoA1, repoA2], + }); + expect(repoB).toMatchObject({ kind: 'repository', repoName: 'owner/repo-b' }); + }); + + it('emits only the repository header when the repository is collapsed', () => { + const repoA1 = notification('a-1', 'owner/repo-a', 0); + const repoA2 = notification('a-2', 'owner/repo-a', 1); + + const items = buildRepositoryItems( + accountUUID, + [repoA1, repoA2], + makeContext({ collapsedRepositories: new Set([`${accountUUID}-owner/repo-a`]) }), + ); + + expect(items.map((item) => item.kind)).toEqual(['repository']); + expect(items[0]).toMatchObject({ key: `repository-${accountUUID}-owner/repo-a` }); + }); + + it('marks notifications as animating exit from either animation source', () => { + const repoA1 = notification('a-1', 'owner/repo-a', 0); + const repoA2 = notification('a-2', 'owner/repo-a', 1); + const repoB1 = notification('b-1', 'owner/repo-b', 2); + + const items = buildRepositoryItems( + accountUUID, + [repoA1, repoA2, repoB1], + makeContext({ + animatingNotifications: new Set([`${accountUUID}:a-1`]), + animatingRepositories: new Map([ + [`${accountUUID}-owner/repo-b`, new Set([`${accountUUID}:b-1`])], + ]), + }), + ); + + const animating = items.filter( + (item) => item.kind === 'notification' && item.isAnimatingExit, + ); + expect(animating.map((item) => item.key)).toEqual([ + `notification-${accountUUID}:a-1`, + `notification-${accountUUID}:b-1`, + ]); + }); + + it('returns an empty list when there are no notifications', () => { + expect(buildRepositoryItems(accountUUID, [], makeContext())).toEqual([]); + }); + }); + + describe('buildAccountItems', () => { + const account = mockGitHubCloudAccount; + const accountUUID = getAccountUUID(account); + + it('emits the account header before sorted notification rows', () => { + const outOfOrder = [ + notification('zz', 'owner/repo-a', 2), + notification('aa', 'owner/repo-a', 0), + notification('mm', 'owner/repo-a', 1), + ]; + + const items = buildAccountItems( + { account, notifications: outOfOrder, error: null }, + makeContext({ showAccountHeader: true, groupBy: GroupBy.DATE }), + ); + + expect(items.map((item) => item.key)).toEqual([ + `account-${accountUUID}`, + `notification-${accountUUID}:aa`, + `notification-${accountUUID}:mm`, + `notification-${accountUUID}:zz`, + ]); + expect(items[0]).toMatchObject({ kind: 'account', account, count: 3 }); + }); + + it('marks notification rows as animating exit', () => { + const items = buildAccountItems( + { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, + makeContext({ + groupBy: GroupBy.DATE, + animatingNotifications: new Set([`${accountUUID}:n-1`]), + }), + ); + + expect(items).toEqual([ + { + key: `notification-${accountUUID}:n-1`, + kind: 'notification', + notification: notification('n-1', 'owner/repo-a', 0), + isAnimatingExit: true, + }, + ]); + }); + + it('returns only the account header when the account is collapsed', () => { + const items = buildAccountItems( + { account, notifications: [notification('n-1', 'owner/repo-a', 0)], error: null }, + makeContext({ + showAccountHeader: true, + collapsedAccounts: new Set([accountUUID]), + groupBy: GroupBy.DATE, + }), + ); + + expect(items.map((item) => item.kind)).toEqual(['account']); + }); + + it('emits an error row, full height when it is the only account', () => { + const error = { title: 'Account failed', descriptions: ['Try again'], emojis: ['🔥'] }; + const entry: Parameters[0] = { + account, + notifications: [notification('n-1', 'owner/repo-a', 0)], + error, + }; + + const singleAccount = buildAccountItems(entry, makeContext({ groupBy: GroupBy.DATE })); + const multipleAccounts = buildAccountItems( + entry, + makeContext({ hasMultipleAccounts: true, groupBy: GroupBy.DATE }), + ); + + expect(singleAccount.map((item) => item.kind)).toEqual(['error', 'notification']); + expect(singleAccount[0]).toMatchObject({ kind: 'error', error, fullHeight: true }); + expect(multipleAccounts[0]).toMatchObject({ kind: 'error', error, fullHeight: false }); + }); + + it('emits an all-read row for a healthy account with no notifications', () => { + const items = buildAccountItems( + { account, notifications: [], error: null }, + makeContext({ groupBy: GroupBy.DATE }), + ); + + expect(items).toEqual([{ key: `all-read-${accountUUID}`, kind: 'all-read' }]); + }); + + it('delegates to repository grouping when groupBy is REPOSITORY', () => { + const repoNotifications = [ + notification('a-1', 'owner/repo-a', 0), + notification('b-1', 'owner/repo-b', 1), + ]; + + const items = buildAccountItems( + { account, notifications: repoNotifications, error: null }, + makeContext(), + ); + + expect(items.map((item) => item.key)).toEqual([ + `repository-${accountUUID}-owner/repo-a`, + `notification-${accountUUID}:a-1`, + `repository-${accountUUID}-owner/repo-b`, + `notification-${accountUUID}:b-1`, + ]); + }); + }); + + describe('NotificationList', () => { + it('renders account headers, repository headers and notification rows flattened in order', () => { + renderWithProviders( + , + { + accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount], + settings: { groupBy: GroupBy.REPOSITORY }, + }, + ); + + expect(screen.getAllByTestId('account-profile')).toHaveLength(2); + expect(screen.getAllByTestId('repository-toggle')).toHaveLength(2); + expect(screen.getAllByTestId('notification-row')).toHaveLength(4); + }); + + it('collapses an account when its header is toggled', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + { + accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount], + settings: { groupBy: GroupBy.REPOSITORY }, + }, + ); + + await user.click(screen.getAllByTestId('account-toggle')[0]); + + expect(screen.getAllByTestId('account-profile')).toHaveLength(2); + expect(screen.getAllByTestId('repository-toggle')).toHaveLength(1); + expect(screen.getAllByTestId('notification-row')).toHaveLength(2); + }); + + it('collapses a repository when its header is toggled', async () => { + const user = userEvent.setup(); + renderWithProviders( + , + { + accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount], + settings: { groupBy: GroupBy.REPOSITORY }, + }, + ); + + await user.click(screen.getAllByTestId('repository-toggle')[0]); + + expect(screen.getAllByTestId('repository-toggle')).toHaveLength(2); + expect(screen.getAllByTestId('notification-row')).toHaveLength(2); + }); + + it('renders an error row for a failed account and an all-read row for an empty account', () => { + renderWithProviders( + , + { + accounts: [mockGitHubCloudAccount, mockGitHubEnterpriseServerAccount], + settings: { groupBy: GroupBy.DATE }, + }, + ); + + expect(screen.getByText('Account failed')).toBeInTheDocument(); + expect(screen.getByText(/No new notifications/)).toBeInTheDocument(); + expect(screen.getAllByTestId('notification-row')).toHaveLength(2); + }); + }); +}); diff --git a/src/renderer/components/notifications/NotificationList.tsx b/src/renderer/components/notifications/NotificationList.tsx index e7c762c72..5df6198ff 100644 --- a/src/renderer/components/notifications/NotificationList.tsx +++ b/src/renderer/components/notifications/NotificationList.tsx @@ -4,7 +4,13 @@ import { LegendList } from '@legendapp/list/react'; import { useAccountsStore, useSettingsStore } from '../../stores'; -import type { Account, AccountNotifications, GitifyError, GitifyNotification } from '../../types'; +import { + GroupBy, + type Account, + type AccountNotifications, + type GitifyError, + type GitifyNotification, +} from '../../types'; import { getAccountUUID } from '../../utils/auth/utils'; import { groupNotificationsByRepository } from '../../utils/notifications/group'; @@ -35,6 +41,124 @@ type ListItem = const getItemKey = (item: ListItem) => item.key; const getItemType = (item: ListItem) => item.kind; +interface BuildItemsContext { + showAccountHeader: boolean; + hasMultipleAccounts: boolean; + collapsedAccounts: ReadonlySet; + collapsedRepositories: ReadonlySet; + animatingNotifications: ReadonlySet; + animatingRepositories: ReadonlyMap>; + groupBy: GroupBy; +} + +function getNotificationExitKey(accountUUID: string, notification: GitifyNotification): string { + return `${accountUUID}:${notification.id}`; +} + +function buildNotificationItem( + accountUUID: string, + notification: GitifyNotification, + isAnimatingExit: boolean, +): ListItem { + return { + key: `notification-${accountUUID}:${notification.id}`, + kind: 'notification', + notification, + isAnimatingExit, + }; +} + +export function buildRepositoryItems( + accountUUID: string, + sorted: GitifyNotification[], + context: BuildItemsContext, +): ListItem[] { + const items: ListItem[] = []; + + for (const [repoName, repoNotifications] of groupNotificationsByRepository(sorted)) { + const repoKey = `${accountUUID}-${repoName}`; + + items.push({ + key: `repository-${repoKey}`, + kind: 'repository', + repoKey, + repoName, + notifications: repoNotifications, + }); + + if (context.collapsedRepositories.has(repoKey)) { + continue; + } + + for (const notification of repoNotifications) { + const exitKey = getNotificationExitKey(accountUUID, notification); + items.push( + buildNotificationItem( + accountUUID, + notification, + context.animatingNotifications.has(exitKey) || + (context.animatingRepositories.get(repoKey)?.has(exitKey) ?? false), + ), + ); + } + } + + return items; +} + +export function buildAccountItems( + { account, error, notifications }: AccountNotifications, + context: BuildItemsContext, +): ListItem[] { + const accountUUID = getAccountUUID(account); + const items: ListItem[] = []; + + if (context.showAccountHeader) { + items.push({ + key: `account-${accountUUID}`, + kind: 'account', + account, + error, + count: notifications.length, + }); + } + + if (context.collapsedAccounts.has(accountUUID)) { + return items; + } + + if (error) { + items.push({ + key: `error-${accountUUID}`, + kind: 'error', + error, + fullHeight: !context.hasMultipleAccounts, + }); + } else if (notifications.length === 0) { + items.push({ key: `all-read-${accountUUID}`, kind: 'all-read' }); + } + + const sorted = [...notifications].sort((a, b) => a.order - b.order); + + if (context.groupBy !== GroupBy.REPOSITORY) { + for (const notification of sorted) { + items.push( + buildNotificationItem( + accountUUID, + notification, + context.animatingNotifications.has(getNotificationExitKey(accountUUID, notification)), + ), + ); + } + + return items; + } + + items.push(...buildRepositoryItems(accountUUID, sorted, context)); + + return items; +} + export interface NotificationListProps { accountNotifications: AccountNotifications[]; showAccountHeader: boolean; @@ -123,81 +247,17 @@ export const NotificationList: FC = ({ }, []); const items = useMemo(() => { - const list: ListItem[] = []; - - for (const { account, error, notifications } of accountNotifications) { - const accountUUID = getAccountUUID(account); - - if (showAccountHeader) { - list.push({ - key: `account-${accountUUID}`, - kind: 'account', - account, - error, - count: notifications.length, - }); - } - - if (collapsedAccounts.has(accountUUID)) { - continue; - } - - if (error) { - list.push({ - key: `error-${accountUUID}`, - kind: 'error', - error, - fullHeight: !hasMultipleAccounts, - }); - } else if (notifications.length === 0) { - list.push({ key: `all-read-${accountUUID}`, kind: 'all-read' }); - } - - const sorted = [...notifications].sort((a, b) => a.order - b.order); - - if (groupBy !== 'REPOSITORY') { - for (const notification of sorted) { - list.push({ - key: `notification-${accountUUID}:${notification.id}`, - kind: 'notification', - notification, - isAnimatingExit: animatingNotifications.has(`${accountUUID}:${notification.id}`), - }); - } - - continue; - } - - for (const [repoName, repoNotifications] of groupNotificationsByRepository(sorted)) { - const repoKey = `${accountUUID}-${repoName}`; - - list.push({ - key: `repository-${repoKey}`, - kind: 'repository', - repoKey, - repoName, - notifications: repoNotifications, - }); - - if (collapsedRepositories.has(repoKey)) { - continue; - } - - for (const notification of repoNotifications) { - list.push({ - key: `notification-${accountUUID}:${notification.id}`, - kind: 'notification', - notification, - isAnimatingExit: - animatingNotifications.has(`${accountUUID}:${notification.id}`) || - (animatingRepositories.get(repoKey)?.has(`${accountUUID}:${notification.id}`) ?? - false), - }); - } - } - } + const context: BuildItemsContext = { + showAccountHeader, + hasMultipleAccounts, + collapsedAccounts, + collapsedRepositories, + animatingNotifications, + animatingRepositories, + groupBy, + }; - return list; + return accountNotifications.flatMap((item) => buildAccountItems(item, context)); }, [ accountNotifications, animatingNotifications,