Add create run (#9203)

* Add playbook start a run

* UI fixes

* Add i18n

* Address feedback

* Fix typo
This commit is contained in:
Daniel Espino García 2025-10-13 21:35:06 +02:00 committed by GitHub
parent 9272efe2a8
commit 468b271928
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1022 additions and 11 deletions

View file

@ -172,7 +172,11 @@ function AutoCompleteSelector({
Promise.all(namePromises).then((names) => {
setItemText(names.join(', '));
});
}, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]);
// We want to run this only in the first render, since it is only for the default value.
// Future changes in the selected value will update the itemText accordingly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const inputStyle = useMemo(() => {
const res: StyleProp<ViewStyle> = [style.input];

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {forceLogoutIfNecessary} from '@actions/remote/session';
import NetworkManager from '@managers/network_manager';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} from '@utils/log';
export async function fetchPlaybooks(serverUrl: string, params: FetchPlaybooksParams) {
try {
const client = NetworkManager.getClient(serverUrl);
const playbooks = await client.fetchPlaybooks(params);
return {data: playbooks};
} catch (error) {
logDebug('error on fetchPlaybooks', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
}

View file

@ -136,6 +136,27 @@ export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId
}
};
export const createPlaybookRun = async (
serverUrl: string,
playbook_id: string,
owner_user_id: string,
team_id: string,
name: string,
description: string,
channel_id?: string,
create_public_run?: boolean,
) => {
try {
const client = NetworkManager.getClient(serverUrl);
const run = await client.createPlaybookRun(playbook_id, owner_user_id, team_id, name, description, channel_id, create_public_run);
return {data: run};
} catch (error) {
logDebug('error on createPlaybookRun', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const postStatusUpdate = async (serverUrl: string, playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => {
try {
const client = NetworkManager.getClient(serverUrl);

View file

@ -7,6 +7,9 @@ import type ClientBase from '@client/rest/base';
export interface ClientPlaybooksMix {
// Playbooks
fetchPlaybooks: (params: FetchPlaybooksParams) => Promise<FetchPlaybooksReturn>;
// Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
@ -14,8 +17,8 @@ export interface ClientPlaybooksMix {
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
// Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
finishRun: (playbookRunId: string) => Promise<void>;
createPlaybookRun: (playbook_id: string, owner_user_id: string, team_id: string, name: string, description: string, channel_id?: string, create_public_run?: boolean) => Promise<PlaybookRun>;
postStatusUpdate: (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => Promise<void>;
// Checklist Management
@ -46,6 +49,17 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
return `${this.getPlaybookRunsRoute()}/${runId}`;
};
// Playbooks
fetchPlaybooks(params: FetchPlaybooksParams) {
const queryParams = buildQueryString({
...params,
});
return this.doFetch(
`${this.getPlaybooksRoute()}/playbooks${queryParams}`,
{method: 'get'},
);
}
// Playbook Runs
fetchPlaybookRuns = async (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => {
const queryParams = buildQueryString(params);
@ -87,6 +101,30 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
);
};
createPlaybookRun = async (
playbook_id: string,
owner_user_id: string,
team_id: string,
name: string,
description: string,
channel_id?: string,
create_public_run?: boolean,
) => {
const data = await this.doFetch(`${this.getPlaybookRunsRoute()}`, {
method: 'post',
body: {
owner_user_id,
team_id,
name,
description,
playbook_id,
channel_id,
create_public_run,
},
});
return data;
};
postStatusUpdate = async (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => {
const body = {
type: 'dialog_submission',

View file

@ -8,6 +8,8 @@ export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
export const PLAYBOOK_POST_UPDATE = 'PlaybookPostUpdate';
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 default {
PLAYBOOKS_RUNS,
@ -17,4 +19,6 @@ export default {
PLAYBOOK_POST_UPDATE,
PLAYBOOK_SELECT_USER,
PLAYBOOKS_SELECT_DATE,
PLAYBOOKS_SELECT_PLAYBOOK,
PLAYBOOKS_START_A_RUN,
} as const;

View file

@ -13,7 +13,9 @@ import PlaybookRun from './playbook_run';
import PlaybookRuns from './playbooks_runs';
import PostUpdate from './post_update';
import SelectDate from './select_date';
import SelectPlaybook from './select_playbook';
import SelectUser from './select_user';
import StartARun from './start_a_run';
import {loadPlaybooksScreen} from '.';
@ -54,6 +56,18 @@ jest.mock('@playbooks/screens/select_date', () => ({
}));
jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</Text>);
jest.mock('@playbooks/screens/start_a_run', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(StartARun).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_START_A_RUN}</Text>);
jest.mock('@playbooks/screens/select_playbook', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(SelectPlaybook).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_PLAYBOOK}</Text>);
jest.mock('@playbooks/screens/participant_playbooks', () => ({
__esModule: true,
default: jest.fn(),

View file

@ -20,6 +20,10 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/select_user').default);
case Screens.PLAYBOOKS_SELECT_DATE:
return withServerDatabase(require('@playbooks/screens/select_date').default);
case Screens.PLAYBOOKS_SELECT_PLAYBOOK:
return withServerDatabase(require('@playbooks/screens/select_playbook').default);
case Screens.PLAYBOOKS_START_A_RUN:
return withServerDatabase(require('@playbooks/screens/start_a_run').default);
default:
return undefined;
}

View file

@ -116,3 +116,31 @@ export async function goToSelectDate(
selectedDate,
}, options);
}
export async function goToSelectPlaybook(
intl: IntlShape,
theme: Theme,
) {
const title = intl.formatMessage({id: 'playbooks.select_playbook.title', defaultMessage: 'Start a run'});
goToScreen(Screens.PLAYBOOKS_SELECT_PLAYBOOK, title, {}, {
topBar: {
subtitle: {
text: intl.formatMessage({id: 'playbooks.select_playbook.subtitle', defaultMessage: 'Select a playbook'}),
color: changeOpacity(theme.sidebarText, 0.72),
},
},
});
}
export async function goToStartARun(intl: IntlShape, theme: Theme, playbook: Playbook, onRunCreated: (run: PlaybookRun) => void) {
const title = intl.formatMessage({id: 'playbooks.start_a_run.title', defaultMessage: 'Start a run'});
const subtitle = playbook.title;
goToScreen(Screens.PLAYBOOKS_START_A_RUN, title, {playbook, onRunCreated}, {
topBar: {
subtitle: {
text: subtitle,
color: changeOpacity(theme.sidebarText, 0.72),
},
},
});
}

View file

@ -3,9 +3,10 @@
import {FlashList, type ListRenderItem} from '@shopify/flash-list';
import React, {useCallback, useMemo, useState} from 'react';
import {defineMessage} from 'react-intl';
import {defineMessage, useIntl} from 'react-intl';
import {StyleSheet, View} from 'react-native';
import Button from '@components/button';
import {Screens} from '@constants';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
@ -15,6 +16,8 @@ import {isRunFinished} from '@playbooks/utils/run';
import {popTopScreen} from '@screens/navigation';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {goToSelectPlaybook} from '../navigation';
import EmptyState from './empty_state';
import PlaybookCard, {CARD_HEIGHT} from './playbook_card';
import ShowMoreButton from './show_more_button';
@ -43,6 +46,9 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({
borderBottomWidth: 1,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.12),
},
startANewRunButtonContainer: {
padding: 20,
},
}));
const ItemSeparator = () => {
@ -71,6 +77,7 @@ const PlaybookRuns = ({
allRuns,
componentId,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const styles = getStyleFromTheme(theme);
@ -132,17 +139,32 @@ const PlaybookRuns = ({
);
}, []);
const startANewRun = useCallback(() => {
goToSelectPlaybook(intl, theme);
}, [intl, theme]);
let content = (<EmptyState tab={activeTab}/>);
if (!isEmpty) {
content = (
<FlashList
data={data}
renderItem={renderItem}
contentContainerStyle={styles.container}
ItemSeparatorComponent={ItemSeparator}
estimatedItemSize={CARD_HEIGHT}
ListFooterComponent={footerComponent}
/>
<>
<FlashList
data={data}
renderItem={renderItem}
contentContainerStyle={styles.container}
ItemSeparatorComponent={ItemSeparator}
estimatedItemSize={CARD_HEIGHT}
ListFooterComponent={footerComponent}
/>
<View style={styles.startANewRunButtonContainer}>
<Button
emphasis='tertiary'
onPress={startANewRun}
text={intl.formatMessage({id: 'playbooks.runs.start_a_new_run', defaultMessage: 'Start a new run'})}
size='lg'
theme={theme}
/>
</View>
</>
);
}

View file

@ -0,0 +1,35 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$} from 'rxjs';
import {switchMap} from 'rxjs/operators';
import {queryPlaybookRunsPerChannel} from '@playbooks/database/queries/run';
import {observeCurrentUserId, observeCurrentTeamId, observeCurrentChannelId} from '@queries/servers/system';
import SelectPlaybook from './select_playbook';
import type PlaybookRunModel from '@playbooks/types/database/models/playbook_run';
import type {WithDatabaseArgs} from '@typings/database/database';
function getPlaybookIdsFromRuns(runs: PlaybookRunModel[]) {
return runs.reduce((acc, run) => {
acc.add(run.playbookId);
return acc;
}, new Set<string>());
}
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
const playbooksUsedInChannel = observeCurrentChannelId(database).pipe(
switchMap((id) => (id ? queryPlaybookRunsPerChannel(database, id).observe() : of$([]))),
switchMap((runs) => of$(getPlaybookIdsFromRuns(runs))),
);
return {
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
playbooksUsedInChannel,
};
});
export default withDatabase(enhanced(SelectPlaybook));

View file

@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl} from 'react-intl';
import {View, Text, TouchableOpacity} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {getFriendlyDate} from '@components/friendly_date';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
type Props = {
playbook: Playbook;
onPress?: (playbook: Playbook) => void;
testID?: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 16,
paddingVertical: 12,
},
contentContainer: {
flex: 1,
flexDirection: 'column',
gap: 2,
},
title: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
statusText: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75, 'Regular'),
},
};
});
const PlaybookRow = ({playbook, onPress, testID}: Props) => {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const handlePress = () => {
onPress?.(playbook);
};
const formatLastUsed = (lastRunAt: number) => {
if (!lastRunAt) {
return intl.formatMessage({
id: 'playbooks.row.never_used',
defaultMessage: 'Never used',
});
}
const formattedTime = getFriendlyDate(intl, lastRunAt);
return intl.formatMessage({
id: 'playbooks.row.last_used',
defaultMessage: 'Last used {time}',
}, {time: formattedTime});
};
const formatRunsInProgress = (activeRuns: number) => {
if (activeRuns === 0) {
return intl.formatMessage({
id: 'playbooks.row.no_runs',
defaultMessage: 'No runs in progress',
});
}
return intl.formatMessage({
id: 'playbooks.row.runs',
defaultMessage: '{count} {count, plural, one {run} other {runs}} in progress',
}, {count: activeRuns});
};
const statusText = `${formatLastUsed(playbook.last_run_at)}${formatRunsInProgress(playbook.active_runs)}`;
return (
<TouchableOpacity
onPress={handlePress}
style={styles.container}
testID={testID}
activeOpacity={0.7}
>
<CompassIcon
name={playbook.public ? 'book-outline' : 'book-lock-outline'}
size={24}
color={changeOpacity(theme.centerChannelColor, 0.64)}
/>
<View style={styles.contentContainer}>
<Text
style={styles.title}
numberOfLines={1}
ellipsizeMode='tail'
>
{playbook.title}
</Text>
<Text
style={styles.statusText}
numberOfLines={1}
ellipsizeMode='tail'
>
{statusText}
</Text>
</View>
</TouchableOpacity>
);
};
export default PlaybookRow;

View file

@ -0,0 +1,339 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {FlatList, SectionList, Text, View, type DefaultSectionT, type ListRenderItemInfo, type SectionListData} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {switchToChannelById} from '@actions/remote/channel';
import FormattedText from '@components/formatted_text';
import Loading from '@components/loading';
import SearchBar from '@components/search';
import {General, Screens} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import SecurityManager from '@managers/security_manager';
import {fetchPlaybooks} from '@playbooks/actions/remote/playbooks';
import {fetchPlaybookRunsForChannel} from '@playbooks/actions/remote/runs';
import {
popTo,
popTopScreen,
} from '@screens/navigation';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import {goToPlaybookRun, goToStartARun} from '../navigation';
import PlaybookRow from './playbook_row';
import type {AvailableScreens} from '@typings/screens/navigation';
const close = () => {
popTopScreen();
};
export type Props = {
currentTeamId: string;
currentUserId: string;
componentId: AvailableScreens;
playbooksUsedInChannel: Set<string>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
paddingHorizontal: 20,
paddingVertical: 24,
gap: 24,
},
searchBar: {
marginVertical: 5,
height: 38,
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
...typography('Body', 600, 'Regular'),
},
searchBarInput: {
color: theme.centerChannelColor,
...typography('Body', 200, 'Regular'),
},
separator: {
height: 1,
flex: 0,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
},
sectionHeader: {
color: changeOpacity(theme.centerChannelColor, 0.56),
...typography('Body', 75, 'SemiBold'),
textTransform: 'uppercase',
},
};
});
const EMPTY_DATA: Playbook[] = [];
function SelectPlaybook({
currentTeamId,
currentUserId,
componentId,
playbooksUsedInChannel,
}: Props) {
const serverUrl = useServerUrl();
const theme = useTheme();
const searchTimeoutId = useRef<NodeJS.Timeout | null>(null);
const style = getStyleSheet(theme);
const intl = useIntl();
// HOOKS
const [loading, setLoading] = useState<boolean>(false);
const [searching, setSearching] = useState<boolean>(false);
const [term, setTerm] = useState<string>('');
const [data, setData] = useState<Playbook[]>([]);
const [searchResults, setSearchResults] = useState<Playbook[]>([]);
const page = useRef<number>(-1);
const next = useRef<boolean>(true);
// Callbacks
const clearSearch = useCallback(() => {
setTerm('');
setSearchResults([]);
setSearching(false);
}, []);
const loadMore = useCallback(async () => {
if (next.current && !loading) {
setLoading(true);
const result = await fetchPlaybooks(serverUrl, {
team_id: currentTeamId,
page: page.current + 1,
});
if (result.data) {
setData((prev) => [...prev, ...result.data.items]);
}
page.current++;
next.current = Boolean(result.data?.has_more);
setLoading(false);
}
}, [loading, serverUrl, currentTeamId]);
const onSearch = useCallback((text: string) => {
if (!text) {
clearSearch();
return;
}
setTerm(text);
setSearching(true);
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
}
searchTimeoutId.current = setTimeout(async () => {
const result = await fetchPlaybooks(serverUrl, {
team_id: currentTeamId,
search_term: text,
sort: 'last_run_at',
});
if (result.data) {
setSearchResults(result.data.items);
}
setSearching(false);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
}, [clearSearch, serverUrl, currentTeamId]);
useAndroidHardwareBackHandler(componentId, close);
useEffect(() => {
return () => {
if (searchTimeoutId.current) {
clearTimeout(searchTimeoutId.current);
searchTimeoutId.current = null;
}
};
}, []);
useEffect(() => {
loadMore();
// We only want to load the playbooks once on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const renderNoResults = useCallback((): JSX.Element | null => {
if (searching || (loading && page.current === -1)) {
return (
<Loading
color={theme.buttonBg}
containerStyle={style.loadingContainer}
size='large'
/>
);
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='playbooks.create_run.select_playbook.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
}, [loading, searching, style.loadingContainer, style.noResultContainer, style.noResultText, theme.buttonBg]);
const onRunCreated = useCallback(async (run: PlaybookRun) => {
await popTo(Screens.HOME);
await fetchPlaybookRunsForChannel(serverUrl, run.channel_id);
await switchToChannelById(serverUrl, run.channel_id);
await goToPlaybookRun(intl, run.id);
}, [intl, serverUrl]);
const onPress = useCallback((playbook: Playbook) => {
goToStartARun(intl, theme, playbook, onRunCreated);
}, [intl, onRunCreated, theme]);
const renderItem = useCallback(({item}: ListRenderItemInfo<Playbook>) => {
return (
<PlaybookRow
playbook={item}
onPress={onPress}
/>
);
}, [onPress]);
const renderLoading = useCallback(() => {
if (!loading) {
return null;
}
return (
<Loading
color={theme.buttonBg}
containerStyle={style.loadingContainer}
size='large'
/>
);
}, [loading, style.loadingContainer, theme.buttonBg]);
const renderSectionHeader = useCallback(({section}: {section: SectionListData<Playbook, DefaultSectionT>}) => {
return (
<Text style={style.sectionHeader}>{section.title}</Text>
);
}, [style.sectionHeader]);
const sections: Array<SectionListData<Playbook, DefaultSectionT>> = useMemo(() => {
type PlaybookSections = {
inThisChannel: Playbook[];
yourPlaybooks: Playbook[];
otherPlaybooks: Playbook[];
}
const reducedPlaybooks = data.reduce<PlaybookSections>((acc, playbook) => {
function isMember(member: PlaybookMember) {
return member.user_id === currentUserId;
}
if (playbooksUsedInChannel.has(playbook.id)) {
acc.inThisChannel.push(playbook);
} else if (playbook.members.some(isMember)) {
acc.yourPlaybooks.push(playbook);
} else {
acc.otherPlaybooks.push(playbook);
}
return acc;
}, {inThisChannel: [], yourPlaybooks: [], otherPlaybooks: []});
const allSections = [
{
title: intl.formatMessage({id: 'playbooks.select_playbook.in_this_channel', defaultMessage: 'In This Channel'}),
data: reducedPlaybooks.inThisChannel,
},
{
title: intl.formatMessage({id: 'playbooks.select_playbook.your_playbooks', defaultMessage: 'Your Playbooks'}),
data: reducedPlaybooks.yourPlaybooks,
},
{
title: intl.formatMessage({id: 'playbooks.select_playbook.other_playbooks', defaultMessage: 'Other Playbooks'}),
data: reducedPlaybooks.otherPlaybooks,
},
];
return allSections.filter((section) => section.data.length > 0);
}, [currentUserId, data, intl, playbooksUsedInChannel]);
let shownData = EMPTY_DATA;
if (!loading) {
if (term) {
shownData = searchResults;
} else {
shownData = data;
}
}
return (
<SafeAreaView
nativeID={SecurityManager.getShieldScreenId(componentId)}
style={style.container}
>
<View
testID='integration_selector.screen'
style={style.searchBar}
>
<SearchBar
testID='selector.search_bar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
inputStyle={style.searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
showLoading={searching}
/>
</View>
{term && (
<FlatList
data={shownData}
renderItem={renderItem}
ListEmptyComponent={renderNoResults}
onEndReached={loadMore}
ListFooterComponent={renderLoading}
/>
)}
{!term && (
<SectionList
sections={sections}
renderSectionHeader={renderSectionHeader}
renderItem={renderItem}
ListEmptyComponent={renderNoResults}
onEndReached={loadMore}
ListFooterComponent={renderLoading}
/>
)}
</SafeAreaView>
);
}
export default SelectPlaybook;

View file

@ -0,0 +1,19 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {observeCurrentUserId, observeCurrentTeamId} from '@queries/servers/system';
import StartARun from './start_a_run';
import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
return {
currentUserId: observeCurrentUserId(database),
currentTeamId: observeCurrentTeamId(database),
};
});
export default withDatabase(enhanced(StartARun));

View file

@ -0,0 +1,280 @@
// 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 {ScrollView, View} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import CompassIcon from '@components/compass_icon';
import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import OptionItem from '@components/option_item';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import useNavButtonPressed from '@hooks/navigation_button_pressed';
import {createPlaybookRun} from '@playbooks/actions/remote/runs';
import {popTopScreen, setButtons} from '@screens/navigation';
import {getFullErrorMessage} from '@utils/errors';
import {logDebug} 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 ChannelOption = 'existing' | 'new';
export type Props = {
componentId: AvailableScreens;
playbook: Playbook;
currentUserId: string;
currentTeamId: string;
onRunCreated: (run: PlaybookRun) => void;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
backgroundColor: theme.centerChannelBg,
},
content: {
flex: 1,
paddingHorizontal: 20,
paddingVertical: 24,
},
contentContainer: {
gap: 16,
},
channelInput: {
marginLeft: 40, // Align with radio button text
},
channelTypeSelectorSection: {
gap: 16,
marginLeft: 40,
},
};
});
const CLOSE_BUTTON_ID = 'close-start-a-run';
const CREATE_BUTTON_ID = 'create-run';
async function makeLeftButton(theme: Theme): Promise<OptionsTopBarButton> {
return {
id: CLOSE_BUTTON_ID,
icon: await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor),
testID: 'start_a_run.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: 'start_a_run.create.button',
enabled,
};
}
function StartARun({
componentId,
playbook,
currentUserId,
currentTeamId,
onRunCreated,
}: Props) {
const theme = useTheme();
const intl = useIntl();
const styles = getStyleSheet(theme);
const serverUrl = useServerUrl();
const [runName, setRunName] = useState(() => {
if (playbook?.channel_mode === 'create_new_channel') {
return playbook.channel_name_template || '';
}
return '';
});
const [runDescription, setRunDescription] = useState(() => {
if (playbook?.run_summary_template_enabled) {
return playbook.run_summary_template || '';
}
return '';
});
const [channelOption, setChannelOption] = useState<ChannelOption>('existing');
const [channelId, setChannelId] = useState<string | undefined>(undefined);
const [createPublicChannel, setCreatePublicChannel] = useState(false);
const canSave = Boolean(runName.trim());
const handleStartRun = useCallback(async () => {
if (!runName.trim()) {
return;
}
const res = await createPlaybookRun(serverUrl, playbook.id, currentUserId, currentTeamId, runName.trim(), runDescription.trim(), channelId, channelOption === 'new' ? createPublicChannel : undefined);
if (res.error || !res.data) {
logDebug('error on createPlaybookRun', getFullErrorMessage(res.error));
showPlaybookErrorSnackbar();
return;
}
await popTopScreen(componentId);
onRunCreated(res.data);
}, [runName, serverUrl, playbook.id, currentUserId, currentTeamId, runDescription, channelId, channelOption, createPublicChannel, componentId, onRunCreated]);
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(() => {
popTopScreen(componentId);
}, [componentId]);
useNavButtonPressed(CREATE_BUTTON_ID, componentId, handleStartRun, [handleStartRun]);
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]);
useAndroidHardwareBackHandler(componentId, close);
const existingOptionAction = useCallback(() => {
setChannelOption('existing');
}, []);
const newOptionAction = useCallback(() => {
setChannelOption('new');
}, []);
const publicChannelOptionAction = useCallback(() => {
setCreatePublicChannel(true);
}, []);
const privateChannelOptionAction = useCallback(() => {
setCreatePublicChannel(false);
}, []);
const onChannelSelected = useCallback((value: SelectedDialogOption) => {
if (Array.isArray(value)) {
// Multiselect case, should never happen
logDebug('on channel selected returned an array, this should never happen', value);
return;
}
if (!value) {
// Undefined case, should never happen
logDebug('on channel selected returned undefined, this should never happen');
return;
}
setChannelId(value.value);
}, []);
return (
<SafeAreaView style={styles.container}>
<ScrollView
style={styles.content}
contentContainerStyle={styles.contentContainer}
>
<FloatingTextInput
label={intl.formatMessage({
id: 'playbooks.start_run.run_name_label',
defaultMessage: 'Run name',
})}
placeholder={intl.formatMessage({
id: 'playbooks.start_run.run_name_placeholder',
defaultMessage: 'Add a name for your run',
})}
value={runName}
onChangeText={setRunName}
theme={theme}
testID='start_run.run_name_input'
error={runName.trim() ? undefined : intl.formatMessage({
id: 'playbooks.start_run.run_name_error',
defaultMessage: 'Please add a name for this run',
})}
/>
<FloatingTextInput
label={intl.formatMessage({
id: 'playbooks.start_run.run_description_label',
defaultMessage: 'Run description',
})}
value={runDescription}
onChangeText={setRunDescription}
multiline={true}
multilineInputHeight={100}
theme={theme}
testID='start_run.run_description_input'
/>
<OptionItem
label={intl.formatMessage({
id: 'playbooks.start_run.link_existing_channel',
defaultMessage: 'Link to an existing channel',
})}
type='radio'
selected={channelOption === 'existing'}
action={existingOptionAction}
testID='start_run.existing_channel_option'
/>
{channelOption === 'existing' && (
<View style={styles.channelInput}>
<FloatingAutocompleteSelector
label={intl.formatMessage({
id: 'playbooks.start_run.channel_label',
defaultMessage: 'Channel',
})}
dataSource='channels'
selected={channelId}
onSelected={onChannelSelected}
testID='start_run.existing_channel_selector'
/>
</View>
)}
<OptionItem
label={intl.formatMessage({
id: 'playbooks.start_run.create_new_channel',
defaultMessage: 'Create a new channel',
})}
type='radio'
selected={channelOption === 'new'}
action={newOptionAction}
testID='start_run.new_channel_option'
/>
{channelOption === 'new' && (
<View style={styles.channelTypeSelectorSection}>
<OptionItem
label={intl.formatMessage({
id: 'playbooks.start_run.create_new_channel.public_channel',
defaultMessage: 'Public channel',
})}
type='radio'
selected={createPublicChannel}
action={publicChannelOptionAction}
testID='start_run.new_channel_public_option'
/>
<OptionItem
label={intl.formatMessage({
id: 'playbooks.start_run.create_new_channel.private_channel',
defaultMessage: 'Private channel',
})}
type='radio'
selected={!createPublicChannel}
action={privateChannelOptionAction}
testID='start_run.new_channel_private_option'
/>
</View>
)}
</ScrollView>
</SafeAreaView>
);
}
export default StartARun;

View file

@ -147,3 +147,33 @@ type PlaybookRunMetadata = {
total_posts: number;
followers: string[];
}
type Playbook = {
id: string;
title: string;
description: string;
team_id: string;
create_public_playbook_run: boolean;
delete_at: number;
run_summary_template_enabled: boolean;
run_summary_template: string;
channel_name_template: string;
channel_mode: string;
public: boolean;
default_owner_id: string;
default_owner_enabled: boolean;
num_stages: number;
num_steps: number;
num_runs: number;
num_actions: number;
last_run_at: number;
members: PlaybookMember[];
default_playbook_member_role: string;
active_runs: number;
}
type PlaybookMember = {
user_id: string;
roles: string[];
scheme_roles?: string[];
}

View file

@ -36,6 +36,23 @@ type FetchPlaybookRunsParams = {
since?: number;
}
type FetchPlaybooksParams = {
team_id: string;
page?: number;
per_page?: number;
sort?: 'title' | 'stages' | 'steps' | 'runs' | 'last_run_at' | 'active_runs';
direction?: 'asc' | 'desc';
search_term?: string;
with_archived?: boolean;
}
type FetchPlaybooksReturn = {
total_count: number;
page_count: number;
has_more: boolean;
items: Playbook[];
}
type PostStatusUpdatePayload = {
message: string;
reminder?: number;

View file

@ -1004,6 +1004,7 @@
"playbooks.checklist_item.skipped": "Skipped",
"playbooks.checklist_item.task_rendered_conditionally": "Task rendered conditionally",
"playbooks.checklist_item.task_rendered_conditionally_explanation": "This task was rendered conditionally based on",
"playbooks.create_run.select_playbook.no_results": "No Results",
"playbooks.due_date.date_at_time": "{date} at {time}",
"playbooks.due_date.none": "None",
"playbooks.edit_command.label": "Command",
@ -1069,15 +1070,35 @@
"playbooks.retrospective_not_available.description": "Only Playbook Runs are available on mobile. To fill the Run Retrospective, please use the desktop or web app.",
"playbooks.retrospective_not_available.ok": "OK",
"playbooks.retrospective_not_available.title": "Playbooks Run Retrospective not available",
"playbooks.row.last_used": "Last used {time}",
"playbooks.row.never_used": "Never used",
"playbooks.row.no_runs": "No runs in progress",
"playbooks.row.runs": "{count} {count, plural, one {run} other {runs}} in progress",
"playbooks.runs.finished.description": "When a run in this channel finishes, youll see it here.",
"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",
"playbooks.runs.start_a_new_run": "Start a new run",
"playbooks.select_date.title": "Due date",
"playbooks.select_playbook.in_this_channel": "In This Channel",
"playbooks.select_playbook.other_playbooks": "Other Playbooks",
"playbooks.select_playbook.subtitle": "Select a playbook",
"playbooks.select_playbook.title": "Start a run",
"playbooks.select_playbook.your_playbooks": "Your Playbooks",
"playbooks.select_user.no_assignee": "No Assignee",
"playbooks.select_user.not_participants": "NOT PARTICIPATING",
"playbooks.select_user.participants": "RUN PARTICIPANTS",
"playbooks.start_a_run.title": "Start a run",
"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",
"playbooks.start_run.create_new_channel.public_channel": "Public channel",
"playbooks.start_run.link_existing_channel": "Link to an existing channel",
"playbooks.start_run.run_description_label": "Run description",
"playbooks.start_run.run_name_error": "Please add a name for this run",
"playbooks.start_run.run_name_label": "Run name",
"playbooks.start_run.run_name_placeholder": "Add a name for your run",
"playbooks.status_update_post.num_tasks": "**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked",
"playbooks.status_update_post.participants": "{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}",
"playbooks.status_update_post.update": "@{authorUsername} posted an update for [{runName}]({link})",