diff --git a/app/products/playbooks/actions/remote/runs.test.ts b/app/products/playbooks/actions/remote/runs.test.ts index bc443f6cf..746604ded 100644 --- a/app/products/playbooks/actions/remote/runs.test.ts +++ b/app/products/playbooks/actions/remote/runs.test.ts @@ -172,7 +172,8 @@ describe('fetchFinishedRunsForChannel', () => { per_page: PER_PAGE_DEFAULT, channel_id: channelId, statuses: ['Finished'], - sort: 'end_at', + sort: 'create_at', + direction: 'desc', }); }); @@ -192,7 +193,8 @@ describe('fetchFinishedRunsForChannel', () => { per_page: PER_PAGE_DEFAULT, channel_id: channelId, statuses: ['Finished'], - sort: 'end_at', + sort: 'create_at', + direction: 'desc', }); }); }); diff --git a/app/products/playbooks/actions/remote/runs.ts b/app/products/playbooks/actions/remote/runs.ts index 83fa77614..c17abe01f 100644 --- a/app/products/playbooks/actions/remote/runs.ts +++ b/app/products/playbooks/actions/remote/runs.ts @@ -72,7 +72,8 @@ export const fetchFinishedRunsForChannel = async (serverUrl: string, channelId: per_page: PER_PAGE_DEFAULT, channel_id: channelId, statuses: ['Finished'], - sort: 'end_at', + sort: 'create_at', + direction: 'desc', }); return {runs, has_more}; diff --git a/app/products/playbooks/database/operators/handlers/index.ts b/app/products/playbooks/database/operators/handlers/index.ts index e57e29cdd..c6c73558b 100644 --- a/app/products/playbooks/database/operators/handlers/index.ts +++ b/app/products/playbooks/database/operators/handlers/index.ts @@ -98,7 +98,7 @@ const PlaybookHandler = >(supe const clauses: Q.Clause[] = [ Q.where('end_at', Q.notEq(0)), - Q.sortBy('end_at', 'desc'), + Q.sortBy('create_at', 'desc'), ]; if (numNewFinished < 5) { diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx index 3c24da7fc..abdb7cc7d 100644 --- a/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx +++ b/app/products/playbooks/screens/playbooks_runs/playbook_runs.test.tsx @@ -85,4 +85,22 @@ describe('PlaybookRuns', () => { expect(queryByTestId('playbook-card')).toBeNull(); expect(getByTestId('empty-state')).toBeTruthy(); }); + + it('shows the show more button only on finished tabs', () => { + const {getByText, queryByText} = renderWithIntl( + , + ); + + expect(queryByText('Show More')).toBeNull(); + + act(() => { + fireEvent.press(getByText('Finished')); + }); + + expect(getByText('Show More')).toBeTruthy(); + }); }); diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx index 953743017..6cc107bbd 100644 --- a/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx +++ b/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx @@ -2,23 +2,22 @@ // See LICENSE.txt for license information. import {FlashList, type ListRenderItem} from '@shopify/flash-list'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {defineMessage} from 'react-intl'; import {StyleSheet, View} from 'react-native'; import {Screens} from '@constants'; -import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; import useTabs, {type TabDefinition} from '@hooks/use_tabs'; import Tabs from '@hooks/use_tabs/tabs'; -import {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs'; import {isRunFinished} from '@playbooks/utils/run'; import {popTopScreen} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import EmptyState from './empty_state'; import PlaybookCard, {CARD_HEIGHT} from './playbook_card'; +import ShowMoreButton from './show_more_button'; import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run'; import type {AvailableScreens} from '@typings/screens/navigation'; @@ -72,12 +71,15 @@ const PlaybookRuns = ({ allRuns, componentId, }: Props) => { - const serverUrl = useServerUrl(); const theme = useTheme(); const styles = getStyleFromTheme(theme); const [fetchedFinishedRuns, setFetchedFinishedRuns] = useState([]); + const pushNewFinishedRuns = useCallback((runs: PlaybookRun[]) => { + setFetchedFinishedRuns((prev) => [...prev, ...runs]); + }, []); + const exit = useCallback(() => { popTopScreen(componentId); }, [componentId]); @@ -98,9 +100,6 @@ const PlaybookRuns = ({ return [inProgress, finished] as const; }, [allRuns]); - const hasMoreFinishedRuns = useRef(true); - const finishedRunsPage = useRef(0); - const fetching = useRef(false); const initialTab: TabsNames = inProgressRuns.length ? 'in-progress' : 'finished'; const [activeTab, tabsProps] = useTabs(initialTab, tabs); @@ -116,6 +115,14 @@ const PlaybookRuns = ({ const isEmpty = data.length === 0; + const footerComponent = useMemo(() => ( + + ), [channelId, pushNewFinishedRuns, activeTab]); + const renderItem: ListRenderItem = useCallback(({item}) => { return ( { - if (fetching.current) { - return; - } - fetching.current = true; - const {runs, has_more, error} = await fetchFinishedRunsForChannel(serverUrl, channelId, finishedRunsPage.current); - fetching.current = false; - if (error) { - hasMoreFinishedRuns.current = false; - return; - } - hasMoreFinishedRuns.current = has_more ?? false; - finishedRunsPage.current++; - if (runs?.length) { - setFetchedFinishedRuns(runs); - } - }, [channelId, serverUrl]); - - const onFinishedRunsReachEnd = useCallback(() => { - if (hasMoreFinishedRuns.current) { - fetchFinishedRuns(); - } - }, [fetchFinishedRuns]); - - useEffect(() => { - if (activeTab === 'finished' && hasMoreFinishedRuns.current && !finishedRuns.length) { - fetchFinishedRuns(); - } - }, [activeTab, fetchFinishedRuns, finishedRuns.length]); - let content = (); if (!isEmpty) { content = ( @@ -164,7 +141,7 @@ const PlaybookRuns = ({ contentContainerStyle={styles.container} ItemSeparatorComponent={ItemSeparator} estimatedItemSize={CARD_HEIGHT} - onEndReached={activeTab === 'finished' ? onFinishedRunsReachEnd : undefined} + ListFooterComponent={footerComponent} /> ); } diff --git a/app/products/playbooks/screens/playbooks_runs/show_more_button.test.tsx b/app/products/playbooks/screens/playbooks_runs/show_more_button.test.tsx new file mode 100644 index 000000000..6b0f56e5c --- /dev/null +++ b/app/products/playbooks/screens/playbooks_runs/show_more_button.test.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act, fireEvent, waitFor} from '@testing-library/react-native'; +import React, {type ComponentProps} from 'react'; + +import {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs'; +import {renderWithIntl} from '@test/intl-test-helper'; +import TestHelper from '@test/test_helper'; + +import ShowMoreButton from './show_more_button'; + +jest.mock('@playbooks/actions/remote/runs'); +jest.mock('@context/server', () => ({ + useServerUrl: jest.fn(() => 'https://test.server.com'), +})); + +describe('ShowMoreButton', () => { + const mockFetchFinishedRunsForChannel = jest.mocked(fetchFinishedRunsForChannel); + + function getBaseProps(): ComponentProps { + return { + channelId: 'test-channel-id', + addMoreRuns: jest.fn(), + visible: true, + }; + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly when hasMore is true', () => { + const props = getBaseProps(); + const {getByText} = renderWithIntl(); + + const button = getByText('Show More'); + expect(button).toBeTruthy(); + }); + + it('does not render when hasMore is false', async () => { + const props = getBaseProps(); + mockFetchFinishedRunsForChannel.mockResolvedValueOnce({ + runs: [], + has_more: false, + }); + + const {getByText, queryByText} = renderWithIntl(); + + // Initially renders the button + expect(getByText('Show More')).toBeTruthy(); + + // Click the button to trigger the fetch + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + // Wait for the component to update and hide + await waitFor(() => { + expect(queryByText('Show More')).toBeNull(); + }); + }); + + it('calls fetchFinishedRunsForChannel with correct parameters when button is pressed', async () => { + const props = getBaseProps(); + mockFetchFinishedRunsForChannel.mockResolvedValueOnce({ + runs: [], + has_more: true, + }); + + const {getByText} = renderWithIntl(); + + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + expect(mockFetchFinishedRunsForChannel).toHaveBeenCalledWith( + 'https://test.server.com', + 'test-channel-id', + 0, + ); + }); + + it('increments page number on subsequent calls', async () => { + const props = getBaseProps(); + mockFetchFinishedRunsForChannel. + mockResolvedValueOnce({ + runs: [], + has_more: true, + }). + mockResolvedValueOnce({ + runs: [], + has_more: true, + }); + + const {getByText} = renderWithIntl(); + + // First click + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + expect(mockFetchFinishedRunsForChannel).toHaveBeenCalledWith( + 'https://test.server.com', + 'test-channel-id', + 0, + ); + + // Wait for DELAY before second click to simulate the double tap prevention + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 800)); // DELAY is 750ms + fireEvent.press(getByText('Show More')); + }); + + expect(mockFetchFinishedRunsForChannel).toHaveBeenCalledWith( + 'https://test.server.com', + 'test-channel-id', + 1, + ); + }); + + it('calls addMoreRuns when runs are returned', async () => { + const props = getBaseProps(); + const mockRuns = [ + TestHelper.fakePlaybookRun({id: 'run-1'}), + TestHelper.fakePlaybookRun({id: 'run-2'}), + ]; + mockFetchFinishedRunsForChannel.mockResolvedValueOnce({ + runs: mockRuns, + has_more: true, + }); + + const {getByText} = renderWithIntl(); + + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + waitFor(() => { + expect(props.addMoreRuns).toHaveBeenCalledWith(mockRuns); + }); + }); + + it('does not call addMoreRuns when no runs are returned', async () => { + const props = getBaseProps(); + mockFetchFinishedRunsForChannel.mockResolvedValueOnce({ + runs: [], + has_more: true, + }); + + const {getByText} = renderWithIntl(); + + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + waitFor(() => { + expect(props.addMoreRuns).not.toHaveBeenCalled(); + }); + }); + + it('sets hasMore to false when API returns an error', async () => { + const props = getBaseProps(); + mockFetchFinishedRunsForChannel.mockResolvedValueOnce({ + error: new Error('API Error'), + }); + + const {getByText, queryByText} = renderWithIntl(); + + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + waitFor(() => { + expect(queryByText('Show More')).toBeNull(); + }); + }); + + it('updates runs and maintains hasMore when API returns runs with has_more true', async () => { + const props = getBaseProps(); + const mockRuns = [TestHelper.fakePlaybookRun({id: 'run-1'})]; + mockFetchFinishedRunsForChannel.mockResolvedValueOnce({ + runs: mockRuns, + has_more: true, + }); + + const {getByText} = renderWithIntl(); + + await act(async () => { + fireEvent.press(getByText('Show More')); + }); + + waitFor(() => { + expect(props.addMoreRuns).toHaveBeenCalledWith(mockRuns); + expect(getByText('Show More')).toBeTruthy(); // Button should still be visible + }); + }); + + it('does not render when visible is false', () => { + const props = getBaseProps(); + props.visible = false; + + const {queryByText} = renderWithIntl(); + expect(queryByText('Show More')).toBeNull(); + }); +}); diff --git a/app/products/playbooks/screens/playbooks_runs/show_more_button.tsx b/app/products/playbooks/screens/playbooks_runs/show_more_button.tsx new file mode 100644 index 000000000..d557dda7b --- /dev/null +++ b/app/products/playbooks/screens/playbooks_runs/show_more_button.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {StyleSheet, View} from 'react-native'; + +import Button from '@components/button'; +import {useServerUrl} from '@context/server'; +import {useTheme} from '@context/theme'; +import {usePreventDoubleTap} from '@hooks/utils'; +import {fetchFinishedRunsForChannel} from '@playbooks/actions/remote/runs'; + +type Props = { + channelId: string; + addMoreRuns: (runs: PlaybookRun[]) => void; + visible: boolean; +}; + +const styles = StyleSheet.create({ + container: { + paddingTop: 16, + }, +}); + +const ShowMoreButton = ({ + channelId, + addMoreRuns, + visible, +}: Props) => { + const intl = useIntl(); + const theme = useTheme(); + const serverUrl = useServerUrl(); + + const [fetching, setFetching] = useState(false); + const [hasMore, setHasMore] = useState(true); + const page = useRef(0); + + const fetchFinishedRuns = usePreventDoubleTap(useCallback(async () => { + if (fetching) { + return; + } + setFetching(true); + const {runs, has_more, error} = await fetchFinishedRunsForChannel(serverUrl, channelId, page.current); + setFetching(false); + if (error) { + setHasMore(false); + return; + } + setHasMore(has_more ?? false); + page.current++; + if (runs?.length) { + addMoreRuns(runs); + } + }, [channelId, fetching, serverUrl, addMoreRuns])); + + if (!hasMore || !visible) { + return null; + } + + return ( + +