Fix order of finished runs and add a show more button (#8999) (#9026)

* Fix order of finished runs and add a show more button

* Add missing text

* Add prevent double tap

* Fix show more button showing when it shouldn't

* fix tests

---------


(cherry picked from commit 80e3659c53)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Elias Nahum <nahumhbl@gmail.com>
This commit is contained in:
Mattermost Build 2025-07-25 08:49:46 +03:00 committed by GitHub
parent dbfbe6dd17
commit 7996ab4fd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 321 additions and 42 deletions

View file

@ -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',
});
});
});

View file

@ -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};

View file

@ -98,7 +98,7 @@ const PlaybookHandler = <TBase extends Constructor<ServerDataOperatorBase>>(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) {

View file

@ -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(
<PlaybookRuns
allRuns={[inProgressRun, finishedRun]}
componentId={'PlaybookRuns'}
channelId={'channel-id-1'}
/>,
);
expect(queryByText('Show More')).toBeNull();
act(() => {
fireEvent.press(getByText('Finished'));
});
expect(getByText('Show More')).toBeTruthy();
});
});

View file

@ -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<PlaybookRun[]>([]);
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<TabsNames>(initialTab, tabs);
@ -116,6 +115,14 @@ const PlaybookRuns = ({
const isEmpty = data.length === 0;
const footerComponent = useMemo(() => (
<ShowMoreButton
channelId={channelId}
addMoreRuns={pushNewFinishedRuns}
visible={activeTab === 'finished'}
/>
), [channelId, pushNewFinishedRuns, activeTab]);
const renderItem: ListRenderItem<PlaybookRunModel> = useCallback(({item}) => {
return (
<PlaybookCard
@ -125,36 +132,6 @@ const PlaybookRuns = ({
);
}, []);
const fetchFinishedRuns = useCallback(async () => {
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 = (<EmptyState tab={activeTab}/>);
if (!isEmpty) {
content = (
@ -164,7 +141,7 @@ const PlaybookRuns = ({
contentContainerStyle={styles.container}
ItemSeparatorComponent={ItemSeparator}
estimatedItemSize={CARD_HEIGHT}
onEndReached={activeTab === 'finished' ? onFinishedRunsReachEnd : undefined}
ListFooterComponent={footerComponent}
/>
);
}

View file

@ -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<typeof ShowMoreButton> {
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
// 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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
// 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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
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(<ShowMoreButton {...props}/>);
expect(queryByText('Show More')).toBeNull();
});
});

View file

@ -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 (
<View style={styles.container}>
<Button
text={intl.formatMessage({id: 'playbooks.runs.show_more', defaultMessage: 'Show More'})}
emphasis='tertiary'
onPress={fetchFinishedRuns}
theme={theme}
showLoader={fetching}
/>
</View>
);
};
export default ShowMoreButton;

View file

@ -1014,6 +1014,7 @@
"playbooks.runs.finished.title": "No finished runs",
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll see it here.",
"playbooks.runs.in_progress.title": "No in progress runs",
"playbooks.runs.show_more": "Show More",
"plus_menu.browse_channels.title": "Browse Channels",
"plus_menu.create_new_channel.title": "Create New Channel",
"plus_menu.invite_people_to_team.title": "Invite people to the team",