[MM-66690][MM-66691] Checklist improvements (#9294)

* fix no response + handle error

* address comment review

* add a screen to allow user to name the checklist

* handle local update errors

* can save as a memoized item

* change touchableopacity into a button

* fix typo

* memoize canSave
This commit is contained in:
Guillermo Vayá 2025-11-19 16:14:03 +01:00 committed by GitHub
parent d0a28cdc67
commit 93b749eb79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 394 additions and 102 deletions

View file

@ -11,6 +11,7 @@ import {
setDueDate as localSetDueDate,
renameChecklist as localRenameChecklist,
} from '@playbooks/actions/local/checklist';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {
updateChecklistItem,
@ -42,9 +43,11 @@ const mockClient = {
setDueDate: jest.fn(),
renameChecklist: jest.fn(),
addChecklistItem: jest.fn(),
fetchPlaybookRun: jest.fn(),
};
jest.mock('@playbooks/actions/local/checklist');
jest.mock('@playbooks/actions/local/run');
const throwFunc = () => {
throw Error('error');
@ -353,7 +356,7 @@ describe('checklist', () => {
it('should handle local DB update failure', async () => {
mockClient.renameChecklist.mockResolvedValueOnce({});
(localRenameChecklist as jest.Mock).mockRejectedValueOnce(new Error('DB error'));
jest.mocked(localRenameChecklist).mockResolvedValueOnce({error: 'DB error'});
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, newTitle);
expect(result).toBeDefined();
@ -364,6 +367,7 @@ describe('checklist', () => {
it('should rename checklist successfully', async () => {
mockClient.renameChecklist.mockResolvedValueOnce({});
jest.mocked(localRenameChecklist).mockResolvedValueOnce({data: true});
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, newTitle);
expect(result).toBeDefined();
@ -375,6 +379,7 @@ describe('checklist', () => {
it('should rename checklist with empty title', async () => {
mockClient.renameChecklist.mockResolvedValueOnce({});
jest.mocked(localRenameChecklist).mockResolvedValueOnce({data: true});
const emptyTitle = '';
const result = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklistId, emptyTitle);
@ -394,7 +399,7 @@ describe('checklist', () => {
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect('error' in result && result.error).toBeDefined();
});
it('should handle API exception', async () => {
@ -402,29 +407,39 @@ describe('checklist', () => {
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect('error' in result && result.error).toBeDefined();
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, title);
});
it('should add checklist item successfully', async () => {
mockClient.addChecklistItem.mockResolvedValueOnce({});
const mockRun = {id: playbookRunId, checklists: []};
mockClient.addChecklistItem.mockResolvedValueOnce(undefined);
mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun);
(handlePlaybookRuns as jest.Mock).mockResolvedValueOnce({data: true});
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect('error' in result ? result.error : undefined).toBeUndefined();
expect('data' in result && result.data).toBe(true);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, title);
expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(playbookRunId);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true);
});
it('should add checklist item with empty title', async () => {
mockClient.addChecklistItem.mockResolvedValueOnce({});
const mockRun = {id: playbookRunId, checklists: []};
const emptyTitle = '';
mockClient.addChecklistItem.mockResolvedValueOnce(undefined);
mockClient.fetchPlaybookRun.mockResolvedValueOnce(mockRun);
(handlePlaybookRuns as jest.Mock).mockResolvedValueOnce({data: true});
const result = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, emptyTitle);
expect(result).toBeDefined();
expect(result.error).toBeUndefined();
expect(result.data).toBe(true);
expect('error' in result ? result.error : undefined).toBeUndefined();
expect('data' in result && result.data).toBe(true);
expect(mockClient.addChecklistItem).toHaveBeenCalledWith(playbookRunId, checklistNumber, emptyTitle);
expect(mockClient.fetchPlaybookRun).toHaveBeenCalledWith(playbookRunId);
expect(handlePlaybookRuns).toHaveBeenCalledWith(serverUrl, [mockRun], false, true);
});
});
});

View file

@ -11,6 +11,7 @@ import {
setDueDate as localSetDueDate,
renameChecklist as localRenameChecklist,
} from '@playbooks/actions/local/checklist';
import {handlePlaybookRuns} from '@playbooks/actions/local/run';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
@ -172,8 +173,8 @@ export const renameChecklist = async (
await client.renameChecklist(playbookRunId, checklistNumber, newTitle);
// Update local database
await localRenameChecklist(serverUrl, checklistId, newTitle);
return {data: true};
const result = await localRenameChecklist(serverUrl, checklistId, newTitle);
return result.error ? result : {data: true};
} catch (error) {
logDebug('error on renameChecklist', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
@ -190,7 +191,11 @@ export const addChecklistItem = async (
try {
const client = NetworkManager.getClient(serverUrl);
await client.addChecklistItem(playbookRunId, checklistNumber, title);
return {data: true};
// Fetch and sync the entire run to get the created item with server-generated ID
const run = await client.fetchPlaybookRun(playbookRunId);
const result = await handlePlaybookRuns(serverUrl, [run], false, true);
return result.error ? result : {data: true};
} catch (error) {
logDebug('error on addChecklistItem', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);

View file

@ -753,6 +753,17 @@ describe('renamePlaybookRun', () => {
expect(localRenamePlaybookRun).not.toHaveBeenCalled();
});
it('should handle local DB update failure', async () => {
mockClient.patchPlaybookRun.mockResolvedValueOnce(undefined);
jest.mocked(localRenamePlaybookRun).mockResolvedValueOnce({error: 'DB error'});
const result = await renamePlaybookRun(serverUrl, playbookRunId, newName);
expect(result).toBeDefined();
expect(result.error).toBeDefined();
expect(mockClient.patchPlaybookRun).toHaveBeenCalledWith(playbookRunId, {name: newName});
expect(localRenamePlaybookRun).toHaveBeenCalledWith(serverUrl, playbookRunId, newName);
});
it('should rename playbook run successfully', async () => {
mockClient.patchPlaybookRun.mockResolvedValueOnce(undefined);

View file

@ -141,8 +141,9 @@ export const renamePlaybookRun = async (serverUrl: string, playbookRunId: string
const client = NetworkManager.getClient(serverUrl);
await client.patchPlaybookRun(playbookRunId, {name: newName});
await localRenamePlaybookRun(serverUrl, playbookRunId, newName);
return {data: true};
// Update local database
const result = await localRenamePlaybookRun(serverUrl, playbookRunId, newName);
return result.error ? result : {data: true};
} catch (error) {
logDebug('error on renamePlaybookRun', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);

View file

@ -13,6 +13,7 @@ export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
export const PLAYBOOKS_SELECT_PLAYBOOK = 'PlaybooksSelectPlaybook';
export const PLAYBOOKS_START_A_RUN = 'PlaybooksStartARun';
export const PLAYBOOKS_CREATE_QUICK_CHECKLIST = 'PlaybooksCreateQuickChecklist';
export default {
PLAYBOOKS_RUNS,
@ -27,4 +28,5 @@ export default {
PLAYBOOKS_SELECT_DATE,
PLAYBOOKS_SELECT_PLAYBOOK,
PLAYBOOKS_START_A_RUN,
PLAYBOOKS_CREATE_QUICK_CHECKLIST,
} as const;

View file

@ -0,0 +1,182 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useCallback, useEffect} from 'react';
import {useIntl, type IntlShape} from 'react-intl';
import {Keyboard, ScrollView} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {usePreventDoubleTap} from '@hooks/utils';
import {createPlaybookRun, fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToPlaybookRun} from '@playbooks/screens/navigation';
import {popTopScreen, setButtons} from '@screens/navigation';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme} from '@utils/theme';
import type {AvailableScreens} from '@typings/screens/navigation';
import type {OptionsTopBarButton} from 'react-native-navigation';
type Props = {
componentId: AvailableScreens;
channelId: string;
channelName: string;
currentUserId: string;
currentTeamId: string;
serverUrl: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
content: {
flex: 1,
paddingHorizontal: 20,
paddingVertical: 24,
},
contentContainer: {
gap: 16,
},
};
});
const CLOSE_BUTTON_ID = 'close-create-quick-checklist';
const CREATE_BUTTON_ID = 'create-quick-checklist';
async function makeLeftButton(theme: Theme): Promise<OptionsTopBarButton> {
return {
id: CLOSE_BUTTON_ID,
icon: await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor),
testID: 'create_quick_checklist.close.button',
};
}
function makeRightButton(theme: Theme, intl: IntlShape, enabled: boolean): OptionsTopBarButton {
return {
color: theme.sidebarHeaderTextColor,
id: CREATE_BUTTON_ID,
text: intl.formatMessage({id: 'mobile.create_channel', defaultMessage: 'Create'}),
showAsAction: 'always',
testID: 'create_quick_checklist.create.button',
enabled,
};
}
function CreateQuickChecklist({
componentId,
channelId,
channelName,
currentUserId,
currentTeamId,
serverUrl,
}: Props) {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const [checklistName, setChecklistName] = useState(`${channelName} Checklist`);
const [description, setDescription] = useState('');
const canSave = Boolean(checklistName.trim());
const handleCreate = usePreventDoubleTap(useCallback(async () => {
const res = await createPlaybookRun(
serverUrl,
'', // empty playbook_id for standalone checklist
currentUserId,
currentTeamId,
checklistName.trim(),
description.trim(),
channelId,
);
if (res.error || !res.data) {
logError('error on createPlaybookRun', getFullErrorMessage(res.error));
showPlaybookErrorSnackbar();
return;
}
// Pop the create screen first, then navigate to the run
// This ensures the back button goes back to the channel, not the create screen
await popTopScreen(componentId);
await fetchPlaybookRunsForChannel(serverUrl, channelId);
await goToPlaybookRun(intl, res.data.id);
}, [serverUrl, currentUserId, currentTeamId, checklistName, description, channelId, componentId, intl]));
useEffect(() => {
async function asyncWrapper() {
const leftButton = await makeLeftButton(theme);
const rightButton = makeRightButton(theme, intl, canSave);
setButtons(
componentId,
{
leftButtons: [leftButton],
rightButtons: [rightButton],
},
);
}
asyncWrapper();
}, [componentId, theme, intl, canSave]);
const close = useCallback(() => {
Keyboard.dismiss();
popTopScreen(componentId);
}, [componentId]);
useNavButtonPressed(CREATE_BUTTON_ID, componentId, handleCreate, [handleCreate]);
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]);
useAndroidHardwareBackHandler(componentId, close);
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<FloatingTextInput
label={intl.formatMessage({
id: 'playbooks.start_run.run_name_label',
defaultMessage: 'Name',
})}
placeholder={intl.formatMessage({
id: 'playbooks.start_run.run_name_placeholder',
defaultMessage: 'Add a name',
})}
value={checklistName}
onChangeText={setChecklistName}
theme={theme}
testID='create_quick_checklist.name_input'
error={checklistName.trim() ? undefined : intl.formatMessage({
id: 'playbooks.start_run.run_name_error',
defaultMessage: 'Please add a name',
})}
/>
<FloatingTextInput
label={intl.formatMessage({
id: 'playbooks.start_checklist.description_label',
defaultMessage: 'Description (optional)',
})}
value={description}
onChangeText={setDescription}
multiline={true}
multilineInputHeight={100}
theme={theme}
testID='create_quick_checklist.description_input'
/>
</ScrollView>
</SafeAreaView>
);
}
export default CreateQuickChecklist;

View file

@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import CreateQuickChecklist from './create_quick_checklist';
export default CreateQuickChecklist;

View file

@ -7,6 +7,7 @@ import {withServerDatabase} from '@database/components';
import Screens from '@playbooks/constants/screens';
import {render} from '@test/intl-test-helper';
import CreateQuickChecklist from './create_quick_checklist';
import EditCommand from './edit_command';
import ParticipantPlaybooks from './participant_playbooks';
import PlaybookRun from './playbook_run';
@ -65,6 +66,12 @@ jest.mock('@playbooks/screens/start_a_run', () => ({
}));
jest.mocked(StartARun).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_START_A_RUN}</Text>);
jest.mock('@playbooks/screens/create_quick_checklist', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(CreateQuickChecklist).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_CREATE_QUICK_CHECKLIST}</Text>);
jest.mock('@playbooks/screens/select_playbook', () => ({
__esModule: true,
default: jest.fn(),

View file

@ -30,6 +30,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/select_playbook').default);
case Screens.PLAYBOOKS_START_A_RUN:
return withServerDatabase(require('@playbooks/screens/start_a_run').default);
case Screens.PLAYBOOKS_CREATE_QUICK_CHECKLIST:
return withServerDatabase(require('@playbooks/screens/create_quick_checklist').default);
default:
return undefined;
}

View file

@ -187,3 +187,23 @@ export async function goToStartARun(intl: IntlShape, theme: Theme, playbook: Pla
},
});
}
export function goToCreateQuickChecklist(
intl: IntlShape,
channelId: string,
channelName: string,
currentUserId: string,
currentTeamId: string,
serverUrl: string,
) {
const screen = Screens.PLAYBOOKS_CREATE_QUICK_CHECKLIST;
const title = intl.formatMessage({id: 'mobile.playbook.create_checklist', defaultMessage: 'Create Checklist'});
goToScreen(screen, title, {
channelId,
channelName,
currentUserId,
currentTeamId,
serverUrl,
}, {}); // Empty options, no subtitle needed
}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
@ -43,7 +43,10 @@ const AddChecklistItemBottomSheet = ({
const theme = useTheme();
const [title, setTitle] = useState<string>('');
const [canSave, setCanSave] = useState(false);
const canSave = useMemo(() => {
return Boolean(title.trim().length);
}, [title]);
const rightButton = React.useMemo(() => {
const base = buildNavigationButton(
@ -63,20 +66,16 @@ const AddChecklistItemBottomSheet = ({
});
}, [rightButton, componentId]);
useEffect(() => {
setCanSave(title.trim().length > 0);
}, [title]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const handleSave = useCallback(() => {
if (title.trim().length > 0) {
if (canSave) {
onSave(title.trim());
close(componentId);
}
}, [title, componentId, onSave]);
}, [canSave, title, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);

View file

@ -4,7 +4,9 @@
import {act, fireEvent, waitFor, within} from '@testing-library/react-native';
import React, {type ComponentProps} from 'react';
import Button from '@components/button';
import ProgressBar from '@playbooks/components/progress_bar';
import {goToAddChecklistItem} from '@playbooks/screens/navigation';
import {renderWithIntl} from '@test/intl-test-helper';
import TestHelper from '@test/test_helper';
@ -21,6 +23,19 @@ jest.mocked(ProgressBar).mockImplementation(
(props) => React.createElement('ProgressBar', {testID: 'progress-bar-component', ...props}),
);
jest.mock('@components/button', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(Button).mockImplementation(
(props) => React.createElement('Button', {testID: props.testID, ...props}),
);
jest.mock('@playbooks/screens/navigation', () => ({
goToRenameChecklist: jest.fn(),
goToAddChecklistItem: jest.fn(),
}));
describe('Checklist', () => {
const mockChecklist = TestHelper.fakePlaybookChecklistModel({
id: 'checklist-1',
@ -228,4 +243,51 @@ describe('Checklist', () => {
expect(progressBar.props.progress).toBe(100);
expect(progressBar.props.isActive).toBe(false);
});
it('renders add button when not finished and is participant', () => {
const props = getBaseProps();
props.isFinished = false;
props.isParticipant = true;
const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const addButton = getByTestId('add-checklist-item-button');
expect(addButton).toBeTruthy();
expect(addButton.props.text).toBe('New');
expect(addButton.props.iconName).toBe('plus');
});
it('does not render add button when finished', () => {
const props = getBaseProps();
props.isFinished = true;
props.isParticipant = true;
const {queryByTestId} = renderWithIntl(<Checklist {...props}/>);
expect(queryByTestId('add-checklist-item-button')).toBeNull();
});
it('does not render add button when not participant', () => {
const props = getBaseProps();
props.isFinished = false;
props.isParticipant = false;
const {queryByTestId} = renderWithIntl(<Checklist {...props}/>);
expect(queryByTestId('add-checklist-item-button')).toBeNull();
});
it('calls goToAddChecklistItem when add button is pressed', () => {
jest.mocked(goToAddChecklistItem).mockClear();
const props = getBaseProps();
props.isFinished = false;
props.isParticipant = true;
const {getByTestId} = renderWithIntl(<Checklist {...props}/>);
const addButton = getByTestId('add-checklist-item-button');
fireEvent.press(addButton);
expect(goToAddChecklistItem).toHaveBeenCalled();
});
});

View file

@ -3,9 +3,10 @@
import React, {useCallback, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity, type LayoutChangeEvent, useWindowDimensions, StyleSheet} from 'react-native';
import {View, Text, TouchableOpacity, type GestureResponderEvent, type LayoutChangeEvent, useWindowDimensions, StyleSheet} from 'react-native';
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import Button from '@components/button';
import CompassIcon from '@components/compass_icon';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
@ -13,6 +14,9 @@ import {renameChecklist, addChecklistItem} from '@playbooks/actions/remote/check
import ProgressBar from '@playbooks/components/progress_bar';
import {goToRenameChecklist, goToAddChecklistItem} from '@playbooks/screens/navigation';
import {getChecklistProgress} from '@playbooks/utils/progress';
import {getFullErrorMessage} from '@utils/errors';
import {logError} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {typography} from '@utils/typography';
@ -140,17 +144,25 @@ const Checklist = ({
setExpanded((prev) => !prev);
}, []);
const handleRename = useCallback((newTitle: string) => {
renameChecklist(serverUrl, playbookRunId, checklistNumber, checklist.id, newTitle);
const handleRename = useCallback(async (newTitle: string) => {
const res = await renameChecklist(serverUrl, playbookRunId, checklistNumber, checklist.id, newTitle);
if ('error' in res && res.error) {
showPlaybookErrorSnackbar();
logError('error on renameChecklist', getFullErrorMessage(res.error));
}
}, [serverUrl, playbookRunId, checklist.id, checklistNumber]);
const handleEditPress = useCallback((e: any) => {
const handleEditPress = useCallback((e: GestureResponderEvent) => {
e.stopPropagation();
goToRenameChecklist(intl, theme, playbookRunName, checklist.title, handleRename);
}, [intl, theme, playbookRunName, checklist.title, handleRename]);
const handleAddItem = useCallback((title: string) => {
addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
const handleAddItem = useCallback(async (title: string) => {
const res = await addChecklistItem(serverUrl, playbookRunId, checklistNumber, title);
if ('error' in res && res.error) {
showPlaybookErrorSnackbar();
logError('error on addChecklistItem', getFullErrorMessage(res.error));
}
}, [serverUrl, playbookRunId, checklistNumber]);
const handleAddPress = useCallback(() => {
@ -231,19 +243,15 @@ const Checklist = ({
/>
))}
{!isFinished && isParticipant && (
<TouchableOpacity
style={styles.addButton}
<Button
text={intl.formatMessage({id: 'playbooks.checklist_item.add.button', defaultMessage: 'New'})}
iconName='plus'
onPress={handleAddPress}
theme={theme}
size='m'
emphasis='tertiary'
testID='add-checklist-item-button'
>
<CompassIcon
name='plus'
style={styles.addButtonIcon}
/>
<Text style={styles.addButtonText}>
{intl.formatMessage({id: 'playbooks.checklist_item.add.button', defaultMessage: 'New'})}
</Text>
</TouchableOpacity>
/>
)}
</Animated.View>
{/* This is a hack to get the height of the checklist items */}

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, StyleSheet, View} from 'react-native';
@ -45,7 +45,10 @@ const RenamePlaybookRunBottomSheet = ({
const theme = useTheme();
const [title, setTitle] = useState<string>(currentTitle);
const [canSave, setCanSave] = useState(false);
const canSave = useMemo(() => {
return title.trim().length > 0 && title !== currentTitle;
}, [title, currentTitle]);
const rightButton = React.useMemo(() => {
const base = buildNavigationButton(
@ -65,20 +68,16 @@ const RenamePlaybookRunBottomSheet = ({
});
}, [rightButton, componentId]);
useEffect(() => {
setCanSave(title.trim().length > 0 && title !== currentTitle);
}, [title, currentTitle]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const handleSave = useCallback(() => {
if (title.trim().length > 0) {
if (canSave) {
onSave(title.trim());
close(componentId);
}
}, [title, componentId, onSave]);
}, [canSave, title, componentId, onSave]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, handleSave, [handleSave]);
useAndroidHardwareBackHandler(componentId, handleClose);

View file

@ -61,7 +61,7 @@ describe('StatusUpdateIndicator', () => {
/>,
);
const text = getByText(/pdate overdue/);
const text = getByText(/Update overdue/);
expect(text).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator});
const icon = getByTestId('compass-icon');

View file

@ -6,8 +6,8 @@ import React, {type ComponentProps} from 'react';
import NavigationHeader from '@components/navigation_header';
import {General} from '@constants';
import {useServerUrl} from '@context/server';
import {createPlaybookRun, fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToPlaybookRun, goToPlaybookRuns} from '@playbooks/screens/navigation';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToCreateQuickChecklist, goToPlaybookRun, goToPlaybookRuns} from '@playbooks/screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {renderWithIntl, waitFor} from '@test/intl-test-helper';
@ -172,15 +172,7 @@ describe('ChannelHeader', () => {
expect(goToPlaybookRun).not.toHaveBeenCalled();
});
it('creates a new playbook run when clicking + button with no active runs', async () => {
const mockCreatedRun = {
id: 'new-run-id',
channel_id: 'channel-id',
name: 'Test Channel Checklist',
} as any;
jest.mocked(createPlaybookRun).mockResolvedValue({data: mockCreatedRun});
jest.mocked(fetchPlaybookRunsForChannel).mockResolvedValue({runs: [mockCreatedRun]});
it('navigates to create quick checklist screen when clicking + button with no active runs', () => {
const props = getBaseProps();
props.playbooksActiveRuns = 0;
props.hasPlaybookRuns = false;
@ -195,19 +187,16 @@ describe('ChannelHeader', () => {
playbookButton?.onPress();
await waitFor(() => {
expect(createPlaybookRun).toHaveBeenCalledWith(
serverUrl,
'', // empty playbook_id
'current-user-id',
'team-id',
'Test Channel Checklist',
'', // empty description
'channel-id',
);
expect(fetchPlaybookRunsForChannel).toHaveBeenCalledWith(serverUrl, 'channel-id');
expect(goToPlaybookRun).toHaveBeenCalledWith(expect.anything(), 'new-run-id');
});
expect(goToCreateQuickChecklist).toHaveBeenCalledWith(
expect.anything(), // intl
'channel-id',
'Test Channel',
'current-user-id',
'team-id',
serverUrl,
);
expect(goToPlaybookRun).not.toHaveBeenCalled();
expect(goToPlaybookRuns).not.toHaveBeenCalled();
});
it('should not fetch runs when playbooks are disabled', async () => {

View file

@ -19,17 +19,14 @@ import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
import {usePreventDoubleTap} from '@hooks/utils';
import {createPlaybookRun, fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToPlaybookRun, goToPlaybookRuns} from '@playbooks/screens/navigation';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {goToCreateQuickChecklist, goToPlaybookRun, goToPlaybookRuns} from '@playbooks/screens/navigation';
import {BOTTOM_SHEET_ANDROID_OFFSET} from '@screens/bottom_sheet';
import ChannelBanner from '@screens/channel/header/channel_banner';
import {bottomSheet, popTopScreen, showModal} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {isTypeDMorGM} from '@utils/channel';
import {getFullErrorMessage} from '@utils/errors';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {logDebug} from '@utils/log';
import {showPlaybookErrorSnackbar} from '@utils/snack_bar';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
@ -219,33 +216,17 @@ const ChannelHeader = ({
});
}, [isTablet, callsAvailable, isDMorGM, hasPlaybookRuns, theme, onTitlePress, channelId]);
const handleCreateQuickRun = useCallback(async () => {
const runName = `${displayName} Checklist`;
const res = await createPlaybookRun(
serverUrl,
'', // empty playbook_id
currentUserId,
teamId,
runName,
'', // empty description
channelId,
);
if (res.error || !res.data) {
logDebug('error on createPlaybookRun', getFullErrorMessage(res.error));
showPlaybookErrorSnackbar();
return;
}
// Fetch updated runs and navigate to the new run
await fetchPlaybookRunsForChannel(serverUrl, channelId);
await goToPlaybookRun(intl, res.data.id);
}, [serverUrl, currentUserId, teamId, displayName, channelId, intl]);
const openPlaybooksRuns = useCallback(() => {
// If no active runs, create a new one instead
if (playbooksActiveRuns === 0) {
handleCreateQuickRun();
goToCreateQuickChecklist(
intl,
channelId,
displayName,
currentUserId,
teamId,
serverUrl,
);
return;
}
@ -254,7 +235,7 @@ const ChannelHeader = ({
return;
}
goToPlaybookRuns(intl, channelId, displayName);
}, [playbooksActiveRuns, handleCreateQuickRun, activeRunId, channelId, displayName, intl]);
}, [playbooksActiveRuns, activeRunId, channelId, displayName, intl, currentUserId, teamId, serverUrl]);
const rightButtons = useMemo(() => {
const buttons: HeaderRightButton[] = [];

View file

@ -776,6 +776,7 @@
"mobile.permalink_preview.originally_posted": "Originally posted in ",
"mobile.permission_denied_dismiss": "Don't Allow",
"mobile.permission_denied_retry": "Settings",
"mobile.playbook.create_checklist": "Create Checklist",
"mobile.post_info.add_reaction": "Add Reaction",
"mobile.post_info.copy_text": "Copy Text",
"mobile.post_info.mark_unread": "Mark as Unread",
@ -1097,6 +1098,7 @@
"playbooks.select_user.not_participants": "NOT PARTICIPATING",
"playbooks.select_user.participants": "PARTICIPANTS",
"playbooks.start_a_run.title": "New",
"playbooks.start_checklist.description_label": "Description (optional)",
"playbooks.start_run.channel_label": "Channel",
"playbooks.start_run.create_new_channel": "Create a new channel",
"playbooks.start_run.create_new_channel.private_channel": "Private channel",