From ff732af9de182fd88829bd97b41413d12c1b28e1 Mon Sep 17 00:00:00 2001 From: Tommaso Casaburi Date: Sat, 23 May 2026 15:24:21 +0700 Subject: [PATCH] fix(mod queue): keep empty state inside loaded table --- .../mod-queue/__tests__/mod-queue.test.tsx | 230 ++++++++++++++++++ src/views/mod-queue/mod-queue.module.css | 6 + src/views/mod-queue/mod-queue.tsx | 18 +- 3 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 src/views/mod-queue/__tests__/mod-queue.test.tsx diff --git a/src/views/mod-queue/__tests__/mod-queue.test.tsx b/src/views/mod-queue/__tests__/mod-queue.test.tsx new file mode 100644 index 00000000..ccb56387 --- /dev/null +++ b/src/views/mod-queue/__tests__/mod-queue.test.tsx @@ -0,0 +1,230 @@ +import * as React from 'react'; +import { createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import ModQueueView from '../mod-queue'; + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +const act = (React as { act?: (callback: () => void | Promise) => void | Promise }).act as (callback: () => void | Promise) => void | Promise; + +type TestComment = { + cid: string; + communityAddress?: string; + pendingApproval?: boolean; +}; + +const testState = vi.hoisted(() => ({ + account: { author: { address: '0x123' }, id: 'account' }, + accountCommunityAddresses: ['music-posting.eth'], + addChallengeMock: vi.fn(), + directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }], + dismissedCommentCids: [] as string[], + feed: [] as TestComment[], + hasMore: false, + isMobile: false, + loadMoreMock: vi.fn(), + publishCommentModerationMock: vi.fn(), + queuedCommentHistory: [] as TestComment[], + rememberCommentsInQueueMock: vi.fn(), + resetMock: vi.fn(), + selectedBoardFilter: null as string | null, + setResetFunctionMock: vi.fn(), + viewMode: 'compact' as 'compact' | 'feed', +})); + +const getModQueueState = () => ({ + dismissedCommentCids: testState.dismissedCommentCids, + dismissCommentFromQueue: vi.fn(), + getAlertThresholdSeconds: () => 6 * 60 * 60, + queuedCommentHistory: testState.queuedCommentHistory, + rememberCommentsInQueue: testState.rememberCommentsInQueueMock, + selectedBoardFilter: testState.selectedBoardFilter, + setSelectedBoardFilter: vi.fn(), + setViewMode: vi.fn(), + viewMode: testState.viewMode, +}); + +function useModQueueStoreMock(selector?: (state: ReturnType) => T) { + const state = getModQueueState(); + return selector ? selector(state) : (state as T); +} +useModQueueStoreMock.getState = getModQueueState; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ + useAccount: () => testState.account, + useCommunity: () => ({ + roles: { + '0x123': { role: 'moderator' }, + }, + state: 'succeeded', + }), + useEditedComment: ({ comment }: { comment?: TestComment }) => ({ + editedComment: comment, + failedEdits: {}, + pendingEdits: {}, + succeededEdits: {}, + }), + useFeed: () => ({ + feed: testState.feed, + hasMore: testState.hasMore, + loadMore: testState.loadMoreMock, + reset: testState.resetMock, + state: testState.hasMore ? 'fetching-ipns' : 'succeeded', + }), + usePublishCommentModeration: () => ({ + publishCommentModeration: testState.publishCommentModerationMock, + state: 'initializing', + }), +})); + +vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({ + default: (selector: (state: { accountsEditsSummaries: Record>; activeAccountId: string }) => unknown) => + selector({ accountsEditsSummaries: { account: {} }, activeAccountId: 'account' }), +})); + +vi.mock('react-virtuoso', () => ({ + Virtuoso: ({ + components, + data = [], + itemContent, + }: { + components?: { Footer?: React.ComponentType }; + data?: TestComment[]; + itemContent: (index: number, item: TestComment) => React.ReactNode; + }) => + createElement( + 'div', + { 'data-testid': 'virtuoso' }, + data.map((item, index) => createElement(React.Fragment, { key: item.cid }, itemContent(index, item))), + components?.Footer ? createElement(components.Footer) : null, + ), +})); + +vi.mock('../../../stores/use-mod-queue-store', () => ({ + default: useModQueueStoreMock, +})); + +vi.mock('../../../stores/use-feed-reset-store', () => ({ + default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => selector({ setResetFunction: testState.setResetFunctionMock }), +})); + +vi.mock('../../../stores/use-challenges-store', () => { + const useChallengesStore = () => ({}); + useChallengesStore.getState = () => ({ addChallenge: testState.addChallengeMock }); + return { default: useChallengesStore }; +}); + +vi.mock('../../../hooks/use-account-community-addresses', () => ({ + useAccountCommunityAddresses: () => testState.accountCommunityAddresses, +})); + +vi.mock('../../../hooks/use-community-identifiers', () => ({ + useCommunityIdentifier: (address: string | undefined) => (address ? { name: address } : undefined), + useCommunityIdentifiers: (addresses: string[]) => addresses.map((address) => ({ name: address })), +})); + +vi.mock('../../../hooks/use-current-time', () => ({ + useCurrentTime: () => 100_000, +})); + +vi.mock('../../../hooks/use-directories', () => ({ + useDirectories: () => testState.directories, +})); + +vi.mock('../../../hooks/use-is-mobile', () => ({ + default: () => testState.isMobile, +})); + +vi.mock('../../../hooks/use-state-string', () => ({ + useFeedStateString: () => 'loading_mod_queue', +})); + +vi.mock('../../../components/error-display/error-display', () => ({ + default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'error'), +})); + +vi.mock('../../../components/footer', () => ({ + PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-desktop' }, firstRow), + PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children), + StyleOnlyFooterFirstRow: () => createElement('div', { 'data-testid': 'style-footer-row' }), +})); + +vi.mock('../../../components/loading-ellipsis', () => ({ + default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), +})); + +vi.mock('../../../components/tooltip', () => ({ + default: ({ children }: { children: React.ReactNode }) => createElement(React.Fragment, {}, children), +})); + +vi.mock('../../post/post', () => ({ + Post: () => createElement('div', { 'data-testid': 'mod-queue-feed-post' }), +})); + +let container: HTMLDivElement; +let root: Root; + +const renderModQueue = async () => { + await act(async () => { + root.render( + createElement( + MemoryRouter, + { initialEntries: ['/mod/queue'] }, + createElement(Routes, {}, createElement(Route, { path: '/mod/queue', element: createElement(ModQueueView) })), + ), + ); + }); +}; + +describe('ModQueueView', () => { + beforeEach(() => { + vi.clearAllMocks(); + testState.dismissedCommentCids = []; + testState.feed = []; + testState.hasMore = false; + testState.isMobile = false; + testState.queuedCommentHistory = []; + testState.selectedBoardFilter = null; + testState.viewMode = 'compact'; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it('keeps the compact table hidden while an empty mod queue is still loading', async () => { + testState.hasMore = true; + + await renderModQueue(); + + expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_mod_queue'); + expect(container.textContent).not.toContain('No.'); + expect(container.textContent).not.toContain('queue_is_empty'); + }); + + it('keeps the compact table visible and renders the empty state under its header after loading', async () => { + testState.hasMore = false; + + await renderModQueue(); + + const text = container.textContent ?? ''; + expect(text).toContain('No.'); + expect(text).toContain('excerpt'); + expect(text).toContain('queue_is_empty'); + expect(text.indexOf('No.')).toBeLessThan(text.indexOf('queue_is_empty')); + }); +}); diff --git a/src/views/mod-queue/mod-queue.module.css b/src/views/mod-queue/mod-queue.module.css index 322ade35..86cbc9e6 100644 --- a/src/views/mod-queue/mod-queue.module.css +++ b/src/views/mod-queue/mod-queue.module.css @@ -113,6 +113,12 @@ color: var(--mod-queue-time-color); } +.emptyTableRow { + margin-top: 1px; + background: var(--mod-queue-row-background-color); + border-bottom: var(--mod-queue-row-border-bottom); +} + .footer { padding: 10px; display: flex; diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 83670343..36dde000 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -1019,6 +1019,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp ); + const isInitialFeedLoading = filteredFeed.length === 0 && hasMore; + const isQueueEmpty = filteredFeed.length === 0 && !hasMore; return ( <> @@ -1031,8 +1033,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp )} - {filteredFeed.length === 0 && !hasMore ? ( -
{t('queue_is_empty')}
+ {isInitialFeedLoading ? ( + ) : ( <> {viewMode === 'compact' && !isMobile && ( @@ -1047,7 +1049,9 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
{t('actions')}
- {hasMore ? ( + {isQueueEmpty ? ( +
{t('queue_is_empty')}
+ ) : hasMore ? ( - {hasMore ? ( + {isQueueEmpty ? ( +
{t('queue_is_empty')}
+ ) : hasMore ? ( - {hasMore ? ( + {isQueueEmpty ? ( +
{t('queue_is_empty')}
+ ) : hasMore ? (