Add post update to playbooks (#9120)

* Add Floating Label Autocomplete Selector

* Add post update to playbooks

* Add intro message and template default

* Fix type issue

* Add missing texts

* Fix test

* Minor fix and refactor

* Address feedback
This commit is contained in:
Daniel Espino García 2025-10-13 20:31:12 +02:00 committed by GitHub
parent b44aace169
commit 6d136a1343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 558 additions and 36 deletions

View file

@ -6,7 +6,6 @@ import {type IntlShape, useIntl} from 'react-intl';
import {Text, View, type StyleProp, type TextStyle, type ViewStyle} from 'react-native'; import {Text, View, type StyleProp, type TextStyle, type ViewStyle} from 'react-native';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens, View as ViewConstants} from '@constants'; import {Screens, View as ViewConstants} from '@constants';
import {useServerUrl} from '@context/server'; import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
@ -48,6 +47,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
flex: 1,
}, },
dropdownPlaceholder: { dropdownPlaceholder: {
color: changeOpacity(theme.centerChannelColor, 0.5), color: changeOpacity(theme.centerChannelColor, 0.5),
@ -174,14 +174,14 @@ function AutoCompleteSelector({
}); });
}, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]); }, [dataSource, teammateNameDisplay, intl, options, selected, serverUrl]);
const touchableStyle = useMemo(() => { const inputStyle = useMemo(() => {
const res: StyleProp<ViewStyle> = [{flex: 1}]; const res: StyleProp<ViewStyle> = [style.input];
if (disabled) { if (disabled) {
res.push(style.disabled); res.push(style.disabled);
} }
return res; return res;
}, [disabled, style.disabled]); }, [disabled, style.disabled, style.input]);
const dropdownTextStyle = useMemo(() => { const dropdownTextStyle = useMemo(() => {
const res: StyleProp<TextStyle> = [style.dropdownText]; const res: StyleProp<TextStyle> = [style.dropdownText];
@ -200,30 +200,24 @@ function AutoCompleteSelector({
error={errorText} error={errorText}
hideErrorIcon={true} hideErrorIcon={true}
theme={theme} theme={theme}
focus={goToSelectorScreen}
focused={false} focused={false}
focusedLabel={focusedLabel} focusedLabel={focusedLabel}
editable={!disabled} editable={!disabled}
testID={testID} testID={testID}
> >
<TouchableWithFeedback <View style={inputStyle}>
disabled={disabled} <Text
onPress={goToSelectorScreen} numberOfLines={1}
style={touchableStyle} style={dropdownTextStyle}
type='opacity' >
> {itemText || placeholder}
<View style={style.input}> </Text>
<Text <CompassIcon
numberOfLines={1} name='chevron-down'
style={dropdownTextStyle} color={changeOpacity(theme.centerChannelColor, 0.5)}
> />
{itemText || placeholder} </View>
</Text>
<CompassIcon
name='chevron-down'
color={changeOpacity(theme.centerChannelColor, 0.5)}
/>
</View>
</TouchableWithFeedback>
</FloatingInputContainer> </FloatingInputContainer>
); );
} }

View file

@ -189,7 +189,7 @@ const FloatingInputContainer = ({
paddingHorizontal: focusedLabel || inputText ? 4 : 0, paddingHorizontal: focusedLabel || inputText ? 4 : 0,
color, color,
}; };
}); }, [styles, theme, focusedLabel, hasValue, shouldShowError, positions]);
return ( return (
<TouchableWithoutFeedback <TouchableWithoutFeedback

View file

@ -89,7 +89,7 @@ export const fetchFinishedRunsForChannel = async (serverUrl: string, channelId:
} }
}; };
export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOnly = false): Promise<PlaybookRunsRequest> => { export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOnly = false) => {
try { try {
const client = NetworkManager.getClient(serverUrl); const client = NetworkManager.getClient(serverUrl);
const run = await client.fetchPlaybookRun(runId); const run = await client.fetchPlaybookRun(runId);
@ -102,7 +102,7 @@ export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOn
} }
} }
return {runs: [run]}; return {run};
} catch (error) { } catch (error) {
logDebug('error on fetchPlaybookRun', getFullErrorMessage(error)); logDebug('error on fetchPlaybookRun', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error); forceLogoutIfNecessary(serverUrl, error);
@ -110,6 +110,18 @@ export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOn
} }
}; };
export const fetchPlaybookRunMetadata = async (serverUrl: string, runId: string) => {
try {
const client = NetworkManager.getClient(serverUrl);
const metadata = await client.fetchPlaybookRunMetadata(runId);
return {metadata};
} catch (error) {
logDebug('error on fetchPlaybookRunMetadata', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};
export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId: string) => { export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId: string) => {
try { try {
const client = NetworkManager.getClient(serverUrl); const client = NetworkManager.getClient(serverUrl);
@ -124,6 +136,16 @@ export const setOwner = async (serverUrl: string, playbookRunId: string, ownerId
} }
}; };
export const postStatusUpdate = async (serverUrl: string, playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => {
try {
const client = NetworkManager.getClient(serverUrl);
await client.postStatusUpdate(playbookRunID, payload, ids);
} catch (error) {
logDebug('error on postStatusUpdate', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
}
};
export const finishRun = async (serverUrl: string, playbookRunId: string) => { export const finishRun = async (serverUrl: string, playbookRunId: string) => {
try { try {
const client = NetworkManager.getClient(serverUrl); const client = NetworkManager.getClient(serverUrl);

View file

@ -10,10 +10,13 @@ export interface ClientPlaybooksMix {
// Playbook Runs // Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>; fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>; fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
fetchPlaybookRunMetadata: (id: string) => Promise<PlaybookRunMetadata>;
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>; setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
// Run Management // Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
finishRun: (playbookRunId: string) => Promise<void>; finishRun: (playbookRunId: string) => Promise<void>;
postStatusUpdate: (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => Promise<void>;
// Checklist Management // Checklist Management
setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>; setChecklistItemState: (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => Promise<void>;
@ -65,6 +68,13 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
); );
}; };
fetchPlaybookRunMetadata = async (id: string) => {
return this.doFetch(
`${this.getPlaybookRunRoute(id)}/metadata`,
{method: 'get'},
);
};
setOwner = async (playbookRunId: string, ownerId: string) => { setOwner = async (playbookRunId: string, ownerId: string) => {
const data = await this.doFetch( const data = await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunId)}/owner`, `${this.getPlaybookRunRoute(playbookRunId)}/owner`,
@ -81,6 +91,26 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
); );
}; };
postStatusUpdate = async (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => {
const body = {
type: 'dialog_submission',
callback_id: '',
state: '',
cancelled: false,
...ids,
submission: {
message: payload.message,
reminder: payload.reminder?.toFixed() ?? '',
finish_run: payload.finishRun,
},
};
await this.doFetch(
`${this.getPlaybookRunRoute(playbookRunID)}/update-status-dialog`,
{method: 'post', body},
);
};
// Checklist Management // Checklist Management
setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => { setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => {
await this.doFetch( await this.doFetch(

View file

@ -4,6 +4,7 @@
export const PLAYBOOKS_RUNS = 'PlaybookRuns'; export const PLAYBOOKS_RUNS = 'PlaybookRuns';
export const PLAYBOOK_RUN = 'PlaybookRun'; export const PLAYBOOK_RUN = 'PlaybookRun';
export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand'; export const PLAYBOOK_EDIT_COMMAND = 'PlaybookEditCommand';
export const PLAYBOOK_POST_UPDATE = 'PlaybookPostUpdate';
export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser'; export const PLAYBOOK_SELECT_USER = 'PlaybookSelectUser';
export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate'; export const PLAYBOOKS_SELECT_DATE = 'PlaybooksSelectDate';
@ -11,6 +12,7 @@ export default {
PLAYBOOKS_RUNS, PLAYBOOKS_RUNS,
PLAYBOOK_RUN, PLAYBOOK_RUN,
PLAYBOOK_EDIT_COMMAND, PLAYBOOK_EDIT_COMMAND,
PLAYBOOK_POST_UPDATE,
PLAYBOOK_SELECT_USER, PLAYBOOK_SELECT_USER,
PLAYBOOKS_SELECT_DATE, PLAYBOOKS_SELECT_DATE,
}; } as const;

View file

@ -10,6 +10,7 @@ import {render} from '@test/intl-test-helper';
import EditCommand from './edit_command'; import EditCommand from './edit_command';
import PlaybookRun from './playbook_run'; import PlaybookRun from './playbook_run';
import PlaybookRuns from './playbooks_runs'; import PlaybookRuns from './playbooks_runs';
import PostUpdate from './post_update';
import SelectDate from './select_date'; import SelectDate from './select_date';
import SelectUser from './select_user'; import SelectUser from './select_user';
@ -52,6 +53,12 @@ jest.mock('@playbooks/screens/select_date', () => ({
})); }));
jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</Text>); jest.mocked(SelectDate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOKS_SELECT_DATE}</Text>);
jest.mock('@playbooks/screens/post_update', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mocked(PostUpdate).mockImplementation((props) => <Text {...props}>{Screens.PLAYBOOK_POST_UPDATE}</Text>);
describe('Screen Registration', () => { describe('Screen Registration', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();

View file

@ -12,6 +12,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/playbook_run').default); return withServerDatabase(require('@playbooks/screens/playbook_run').default);
case Screens.PLAYBOOK_EDIT_COMMAND: case Screens.PLAYBOOK_EDIT_COMMAND:
return withServerDatabase(require('@playbooks/screens/edit_command').default); return withServerDatabase(require('@playbooks/screens/edit_command').default);
case Screens.PLAYBOOK_POST_UPDATE:
return withServerDatabase(require('@playbooks/screens/post_update').default);
case Screens.PLAYBOOK_SELECT_USER: case Screens.PLAYBOOK_SELECT_USER:
return withServerDatabase(require('@playbooks/screens/select_user').default); return withServerDatabase(require('@playbooks/screens/select_user').default);
case Screens.PLAYBOOKS_SELECT_DATE: case Screens.PLAYBOOKS_SELECT_DATE:

View file

@ -26,6 +26,11 @@ export async function goToPlaybookRun(intl: IntlShape, playbookRunId: string, pl
goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId, playbookRun}, {}); goToScreen(Screens.PLAYBOOK_RUN, title, {playbookRunId, playbookRun}, {});
} }
export async function goToPostUpdate(intl: IntlShape, playbookRunId: string) {
const title = intl.formatMessage({id: 'playbooks.post_update.title', defaultMessage: 'Post update'});
goToScreen(Screens.PLAYBOOK_POST_UPDATE, title, {playbookRunId}, {});
}
function getSubtitleOptions(theme: Theme, runName: string): RNNOptions { function getSubtitleOptions(theme: Theme, runName: string): RNNOptions {
return { return {
topBar: { topBar: {

View file

@ -337,6 +337,8 @@ export default function PlaybookRun({
<StatusUpdateIndicator <StatusUpdateIndicator
isFinished={isFinished} isFinished={isFinished}
timestamp={getRunScheduledTimestamp(playbookRun)} timestamp={getRunScheduledTimestamp(playbookRun)}
isParticipant={isParticipant}
playbookRunId={playbookRun.id}
/> />
</View> </View>
<View style={styles.tasksContainer}> <View style={styles.tasksContainer}>

View file

@ -32,6 +32,8 @@ describe('StatusUpdateIndicator', () => {
<StatusUpdateIndicator <StatusUpdateIndicator
isFinished={false} isFinished={false}
timestamp={futureTimestamp} timestamp={futureTimestamp}
isParticipant={true}
playbookRunId='run-id'
/>, />,
); );
@ -48,6 +50,8 @@ describe('StatusUpdateIndicator', () => {
<StatusUpdateIndicator <StatusUpdateIndicator
isFinished={false} isFinished={false}
timestamp={pastTimestamp} timestamp={pastTimestamp}
isParticipant={true}
playbookRunId='run-id'
/>, />,
); );
@ -64,6 +68,8 @@ describe('StatusUpdateIndicator', () => {
<StatusUpdateIndicator <StatusUpdateIndicator
isFinished={true} isFinished={true}
timestamp={pastTimestamp} timestamp={pastTimestamp}
isParticipant={true}
playbookRunId='run-id'
/>, />,
); );

View file

@ -1,19 +1,24 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useMemo} from 'react'; import React, {useCallback, useMemo} from 'react';
import {defineMessages, useIntl} from 'react-intl'; import {defineMessages, useIntl} from 'react-intl';
import {View, Text} from 'react-native'; import {View, Text} from 'react-native';
import Button from '@components/button';
import CompassIcon from '@components/compass_icon'; import CompassIcon from '@components/compass_icon';
import FriendlyDate from '@components/friendly_date'; import FriendlyDate from '@components/friendly_date';
import {useTheme} from '@context/theme'; import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography'; import {typography} from '@utils/typography';
import {goToPostUpdate} from '../navigation';
type StatusUpdateIndicatorProps = { type StatusUpdateIndicatorProps = {
isFinished: boolean; isFinished: boolean;
isParticipant: boolean;
timestamp: number; timestamp: number;
playbookRunId: string;
} }
const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
@ -27,6 +32,15 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({
borderColor: changeOpacity(theme.centerChannelColor, 0.16), borderColor: changeOpacity(theme.centerChannelColor, 0.16),
gap: 8, gap: 8,
}, },
due: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
},
dueTextContainer: {
flex: 1,
},
icon: { icon: {
fontSize: 24, fontSize: 24,
color: changeOpacity(theme.centerChannelColor, 0.72), color: changeOpacity(theme.centerChannelColor, 0.72),
@ -56,17 +70,24 @@ const messages = defineMessages({
id: 'playbooks.playbook_run.status_update_finished', id: 'playbooks.playbook_run.status_update_finished',
defaultMessage: 'Run finished {time}', defaultMessage: 'Run finished {time}',
}, },
update: {
id: 'playbooks.playbook_run.status_update',
defaultMessage: 'Post update',
},
}); });
const StatusUpdateIndicator = ({ const StatusUpdateIndicator = ({
isFinished, isFinished,
timestamp, timestamp,
isParticipant,
playbookRunId,
}: StatusUpdateIndicatorProps) => { }: StatusUpdateIndicatorProps) => {
const theme = useTheme(); const theme = useTheme();
const styles = getStyleSheet(theme); const styles = getStyleSheet(theme);
const intl = useIntl(); const intl = useIntl();
const values = {time: <FriendlyDate value={timestamp}/>}; const values = {time: <FriendlyDate value={timestamp}/>};
const readOnly = !isParticipant || isFinished;
let message = messages.updateDue; let message = messages.updateDue;
if (isFinished) { if (isFinished) {
@ -88,16 +109,35 @@ const StatusUpdateIndicator = ({
]; ];
}, [styles.icon, styles.overdueText, isFinished, timestamp]); }, [styles.icon, styles.overdueText, isFinished, timestamp]);
const onUpdatePress = useCallback(async () => {
if (readOnly) {
return;
}
await goToPostUpdate(intl, playbookRunId);
}, [intl, playbookRunId, readOnly]);
const icon = isFinished ? 'flag-checkered' : 'clock-outline'; const icon = isFinished ? 'flag-checkered' : 'clock-outline';
return ( return (
<View style={styles.container}> <View style={styles.container}>
<CompassIcon <View style={styles.due}>
name={icon} <CompassIcon
style={iconStyle} name={icon}
style={iconStyle}
/>
<View style={styles.dueTextContainer}>
<Text style={textStyle}>
{intl.formatMessage(message, values)}
</Text>
</View>
</View>
<Button
text={intl.formatMessage(messages.update)}
onPress={onUpdatePress}
theme={theme}
size='lg'
disabled={readOnly}
/> />
<Text style={textStyle}>
{intl.formatMessage(message, values)}
</Text>
</View> </View>
); );
}; };

View file

@ -0,0 +1,56 @@
// 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 {queryPlaybookChecklistByRun} from '@playbooks/database/queries/checklist';
import {queryPlaybookChecklistItemsByChecklists} from '@playbooks/database/queries/item';
import {observePlaybookRunById} from '@playbooks/database/queries/run';
import {isOutstanding} from '@playbooks/utils/run';
import {observeCurrentTeamId, observeCurrentUserId} from '@queries/servers/system';
import PostUpdate from './post_update';
import type PlaybookChecklistModel from '@playbooks/types/database/models/playbook_checklist';
import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
playbookRunId: string;
playbookRun?: PlaybookRun;
} & WithDatabaseArgs;
const getIds = (checklists: PlaybookChecklistModel[]) => {
return checklists.map((c) => c.id);
};
const enhanced = withObservables(['playbookRunId'], ({playbookRunId, database}: OwnProps) => {
const playbookRun = observePlaybookRunById(database, playbookRunId);
const checklists = queryPlaybookChecklistByRun(database, playbookRunId).observe();
const outstandingCount = checklists.pipe(
switchMap((cs) => {
const ids = getIds(cs);
return queryPlaybookChecklistItemsByChecklists(database, ids).observeWithColumns(['state']);
}),
switchMap((items) => {
const overdue = items.filter(isOutstanding).length;
return of$(overdue);
}),
);
return {
runName: playbookRun.pipe(
switchMap((r) => of$(r?.name ?? '')),
),
userId: observeCurrentUserId(database),
channelId: playbookRun.pipe(
switchMap((r) => (r ? of$(r.channelId) : of$(undefined))),
),
teamId: observeCurrentTeamId(database),
outstanding: outstandingCount,
};
});
export default withDatabase(enhanced(PostUpdate));

View file

@ -0,0 +1,303 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {type ComponentProps, useCallback, useEffect, useMemo, useState} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {Alert, Keyboard, Text, View} from 'react-native';
import {getPosts} from '@actions/local/post';
import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector';
import FloatingTextInput from '@components/floating_input/floating_text_input_label';
import Loading from '@components/loading';
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 {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@playbooks/actions/remote/runs';
import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation';
import {toSeconds} from '@utils/datetime';
import {logDebug} from '@utils/log';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
componentId: AvailableScreens;
playbookRunId: string;
runName: string;
userId: string;
channelId?: string;
teamId: string;
outstanding: number;
}
const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({
container: {
flex: 1,
padding: 20,
gap: 12,
},
introMessage: {
...typography('Body', 200),
color: changeOpacity(theme.centerChannelColor, 0.72),
},
introMessageBold: {
...typography('Body', 200, 'SemiBold'),
},
}));
const SAVE_BUTTON_ID = 'save-post-update';
const close = (componentId: AvailableScreens): void => {
Keyboard.dismiss();
popTopScreen(componentId);
};
const valueToTimeMap = {
'15_minutes': toSeconds({minutes: 15}),
'30_minutes': toSeconds({minutes: 30}),
'1_hour': toSeconds({hours: 1}),
'4_hours': toSeconds({hours: 4}),
'1_day': toSeconds({days: 1}),
'7_days': toSeconds({days: 7}),
};
type NextUpdateValues = keyof typeof valueToTimeMap;
const PostUpdate = ({
componentId,
playbookRunId,
runName,
userId,
channelId,
teamId,
outstanding,
}: Props) => {
const theme = useTheme();
const intl = useIntl();
const serverUrl = useServerUrl();
const styles = getStyles(theme);
const [updateMessage, setUpdateMessage] = useState('');
const [nextUpdate, setNextUpdate] = useState<NextUpdateValues>('15_minutes');
const [alsoMarkRunAsFinished, setAlsoMarkRunAsFinished] = useState(false);
const [canSave, setCanSave] = useState(false);
const [followersCount, setFollowersCount] = useState<number>(0);
const [broadcastChannelCount, setBroadcastChannelCount] = useState<number>(0);
const [loading, setLoading] = useState(true);
const onChangeText = useCallback((text: string) => {
setUpdateMessage(text);
setCanSave(text.trim().length > 0);
}, []);
const rightButton = useMemo(() => {
const base = buildNavigationButton(
SAVE_BUTTON_ID,
'playbooks.edit_command.save.button',
undefined,
intl.formatMessage({id: 'playbooks.post_update.post.button', defaultMessage: 'Post'}),
);
base.enabled = canSave;
base.color = theme.sidebarHeaderTextColor;
return base;
}, [intl, canSave, theme.sidebarHeaderTextColor]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [rightButton, componentId]);
useEffect(() => {
async function initialLoad() {
let calculatedFollowersCount = 0;
let calculatedBroadcastChannelCount = 0;
let calculatedDefaultMessage = '';
const metadataRes = await fetchPlaybookRunMetadata(serverUrl, playbookRunId);
if (metadataRes.error) {
logDebug('error on fetchPlaybookRunMetadata', metadataRes.error);
} else {
calculatedFollowersCount = metadataRes.metadata?.followers?.length ?? 0;
}
const runRes = await fetchPlaybookRun(serverUrl, playbookRunId, true);
if (runRes.error) {
logDebug('error on fetchPlaybookRun', runRes.error);
} else {
if (runRes.run?.status_update_broadcast_channels_enabled) {
calculatedBroadcastChannelCount = runRes.run?.broadcast_channel_ids?.length ?? 0;
}
calculatedDefaultMessage = runRes.run?.reminder_message_template ?? '';
const lastStatusPostMetadata = runRes.run?.status_posts?.slice().reverse().find((post) => !post.delete_at);
if (lastStatusPostMetadata?.id) {
const lastStatusPost = (await getPosts(serverUrl, [lastStatusPostMetadata?.id ?? '']))[0];
if (lastStatusPost) {
calculatedDefaultMessage = lastStatusPost.message;
}
}
}
setFollowersCount(calculatedFollowersCount);
setBroadcastChannelCount(calculatedBroadcastChannelCount);
setUpdateMessage(calculatedDefaultMessage);
setCanSave(calculatedDefaultMessage.trim().length > 0);
setLoading(false);
}
initialLoad();
// This is the initial load, so we don't need to re-run it on every change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onNextUpdateSelected = useCallback((value: SelectedDialogOption) => {
if (!value) {
return;
}
if (Array.isArray(value)) {
// Multiselect, which should never happen
return;
}
setNextUpdate(value.value as NextUpdateValues);
}, [setNextUpdate]);
const dialogOptions = useMemo<DialogOption[]>(() => {
return [
{
text: intl.formatMessage({id: 'playbooks.post_update.option.15_minutes', defaultMessage: '15 minutes'}),
value: '15_minutes',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.option.30_minutes', defaultMessage: '30 minutes'}),
value: '30_minutes',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.option.1_hour', defaultMessage: '1 hour'}),
value: '1_hour',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.option.4_hours', defaultMessage: '4 hours'}),
value: '4_hours',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.option.1_day', defaultMessage: '1 day'}),
value: '1_day',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.option.7_days', defaultMessage: '7 days'}),
value: '7_days',
},
];
}, [intl]);
const handleClose = useCallback(() => {
close(componentId);
}, [componentId]);
const onConfirm = useCallback(() => {
close(componentId);
if (!channelId) {
// This should never happen, but this keeps typescript happy
logDebug('cannot post status update without a channel id');
return;
}
postStatusUpdate(serverUrl, playbookRunId, {message: updateMessage, reminder: valueToTimeMap[nextUpdate], finishRun: alsoMarkRunAsFinished}, {user_id: userId, channel_id: channelId, team_id: teamId});
}, [alsoMarkRunAsFinished, channelId, componentId, nextUpdate, playbookRunId, serverUrl, teamId, updateMessage, userId]);
const onPostUpdate = useCallback(() => {
if (alsoMarkRunAsFinished) {
let message = intl.formatMessage({id: 'playbooks.post_update.confirm.message', defaultMessage: 'Are you sure you want to finish the run {runName} for all participants?'}, {runName});
if (outstanding > 0) {
message = intl.formatMessage({id: 'playbooks.post_update.confirm.message.with_tasks', defaultMessage: 'There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the run {runName} for all participants?'}, {runName, outstanding});
}
Alert.alert(
intl.formatMessage({id: 'playbooks.post_update.confirm.title', defaultMessage: 'Confirm finish run'}),
message,
[
{
text: intl.formatMessage({id: 'playbooks.post_update.confirm.cancel', defaultMessage: 'Cancel'}),
style: 'cancel',
},
{
text: intl.formatMessage({id: 'playbooks.post_update.confirm.confirm', defaultMessage: 'Finish run'}),
onPress: onConfirm,
},
],
);
} else {
onConfirm();
}
}, [alsoMarkRunAsFinished, intl, onConfirm, outstanding, runName]);
useNavButtonPressed(SAVE_BUTTON_ID, componentId, onPostUpdate, [onPostUpdate]);
useAndroidHardwareBackHandler(componentId, handleClose);
const introMessageValues = useMemo<ComponentProps<typeof FormattedMessage>['values']>(() => {
return {
runName,
hasFollowersAndChannels: Boolean(followersCount && broadcastChannelCount).toString(),
hasChannels: Boolean(broadcastChannelCount).toString(),
hasFollowers: Boolean(followersCount).toString(),
broadcastChannelCount,
followersChannelCount: followersCount,
Bold: (...chunks) => <Text style={styles.introMessageBold}>{chunks}</Text>,
};
}, [runName, followersCount, broadcastChannelCount, styles.introMessageBold]);
if (loading) {
return <Loading/>;
}
let introMessage;
if (broadcastChannelCount + followersCount === 0) {
introMessage = (
<FormattedMessage
id='playbooks.post_update.intro.no_followers_or_broadcast_channels'
defaultMessage='This update will be saved to the overview page.'
/>
);
} else {
introMessage = (
<FormattedMessage
id='playbooks.post_update.intro'
defaultMessage='This update for the run <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.'
values={introMessageValues}
/>
);
}
return (
<View style={styles.container}>
<Text style={styles.introMessage}>{introMessage}</Text>
<FloatingTextInput
label={intl.formatMessage({id: 'playbooks.post_update.label', defaultMessage: 'Update message'})}
placeholder={intl.formatMessage({id: 'playbooks.post_update.placeholder', defaultMessage: 'Enter your update message'})}
value={updateMessage}
onChangeText={onChangeText}
theme={theme}
multiline={true}
/>
<FloatingAutocompleteSelector
options={dialogOptions}
testID='playbooks.post_update.selector'
selected={nextUpdate}
onSelected={onNextUpdateSelected}
label={intl.formatMessage({id: 'playbooks.post_update.label.next_update', defaultMessage: 'Timer for next update'})}
/>
<OptionItem
label={intl.formatMessage({id: 'playbooks.post_update.label.also_mark_run_as_finished', defaultMessage: 'Also mark the run as finished'})}
action={setAlsoMarkRunAsFinished}
testID='playbooks.post_update.selector.also_mark_run_as_finished'
selected={alsoMarkRunAsFinished}
type='toggle'
/>
</View>
);
};
export default PostUpdate;

View file

@ -32,7 +32,7 @@ describe('SelectDate', () => {
function getBaseProps(): ComponentProps<typeof SelectDate> { function getBaseProps(): ComponentProps<typeof SelectDate> {
const mockOnSave = jest.fn(); const mockOnSave = jest.fn();
const mockComponentId: AvailableScreens = 'SelectDate'; const mockComponentId: AvailableScreens = 'PlaybooksSelectDate';
const mockCurrentUserTimezone: UserTimezone = { const mockCurrentUserTimezone: UserTimezone = {
automaticTimezone: 'UTC', automaticTimezone: 'UTC',
manualTimezone: '', manualTimezone: '',

View file

@ -54,6 +54,7 @@ type RunMetricData = {
type StatusPost = { type StatusPost = {
id: string; id: string;
create_at: number; create_at: number;
delete_at: number;
} }
type TimelineEvent = { type TimelineEvent = {
@ -135,4 +136,14 @@ type PlaybookRun = {
metrics_data: RunMetricData[]; metrics_data: RunMetricData[];
update_at: number; update_at: number;
items_order: string[]; items_order: string[];
status_update_broadcast_channels_enabled: boolean;
}
type PlaybookRunMetadata = {
channel_name: string;
channel_display_name: string;
team_name: string;
num_participants: number;
total_posts: number;
followers: string[];
} }

View file

@ -35,3 +35,15 @@ type FetchPlaybookRunsParams = {
channel_id?: string; channel_id?: string;
since?: number; since?: number;
} }
type PostStatusUpdatePayload = {
message: string;
reminder?: number;
finishRun: boolean;
};
type PostStatusUpdateIds = {
user_id: string;
channel_id: string;
team_id: string;
};

View file

@ -70,3 +70,7 @@ export function isDueSoon(item: PlaybookChecklistItemModel | PlaybookChecklistIt
return dueDate < Date.now() + toMilliseconds({hours: 12}); return dueDate < Date.now() + toMilliseconds({hours: 12});
} }
export function isOutstanding(item: PlaybookChecklistItemModel | PlaybookChecklistItem): boolean {
return item.state === '' || item.state === 'in_progress';
}

View file

@ -27,10 +27,15 @@ export function isYesterday(date: Date): boolean {
} }
export function toMilliseconds({days, hours, minutes, seconds}: {days?: number; hours?: number; minutes?: number; seconds?: number}) { export function toMilliseconds({days, hours, minutes, seconds}: {days?: number; hours?: number; minutes?: number; seconds?: number}) {
const totalSeconds = toSeconds({days, hours, minutes, seconds});
return totalSeconds * 1000;
}
export function toSeconds({days, hours, minutes, seconds}: {days?: number; hours?: number; minutes?: number; seconds?: number}) {
const totalHours = ((days || 0) * 24) + (hours || 0); const totalHours = ((days || 0) * 24) + (hours || 0);
const totalMinutes = (totalHours * 60) + (minutes || 0); const totalMinutes = (totalHours * 60) + (minutes || 0);
const totalSeconds = (totalMinutes * 60) + (seconds || 0); const totalSeconds = (totalMinutes * 60) + (seconds || 0);
return totalSeconds * 1000; return totalSeconds;
} }
export function getReadableTimestamp(timestamp: number, timeZone: string, isMilitaryTime: boolean, currentUserLocale: string): string { export function getReadableTimestamp(timestamp: number, timeZone: string, isMilitaryTime: boolean, currentUserLocale: string): string {

View file

@ -1034,12 +1034,32 @@
"playbooks.playbook_run.participants": "Participants", "playbooks.playbook_run.participants": "Participants",
"playbooks.playbook_run.participants_title": "Run Participants", "playbooks.playbook_run.participants_title": "Run Participants",
"playbooks.playbook_run.run_details": "Run details", "playbooks.playbook_run.run_details": "Run details",
"playbooks.playbook_run.status_update": "Post update",
"playbooks.playbook_run.status_update_due": "Status update due {time}", "playbooks.playbook_run.status_update_due": "Status update due {time}",
"playbooks.playbook_run.status_update_finished": "Run finished {time}", "playbooks.playbook_run.status_update_finished": "Run finished {time}",
"playbooks.playbook_run.status_update_overdue": "Status update overdue {time}", "playbooks.playbook_run.status_update_overdue": "Status update overdue {time}",
"playbooks.playbook_run.tasks": "Tasks", "playbooks.playbook_run.tasks": "Tasks",
"playbooks.playbook_run.title": "Playbook run", "playbooks.playbook_run.title": "Playbook run",
"playbooks.playbooks_runs.title": "Playbook runs", "playbooks.playbooks_runs.title": "Playbook runs",
"playbooks.post_update.confirm.cancel": "Cancel",
"playbooks.post_update.confirm.confirm": "Finish run",
"playbooks.post_update.confirm.message": "Are you sure you want to finish the run {runName} for all participants?",
"playbooks.post_update.confirm.message.with_tasks": "There {outstanding, plural, =1 {is # outstanding task} other {are # outstanding tasks}}. Are you sure you want to finish the run {runName} for all participants?",
"playbooks.post_update.confirm.title": "Confirm finish run",
"playbooks.post_update.intro": "This update for the run <Bold>{runName}</Bold> will be broadcasted to {hasChannels, select, true {<Bold>{broadcastChannelCount, plural, =1 {one channel} other {{broadcastChannelCount, number} channels}}</Bold>} other {}}{hasFollowersAndChannels, select, true { and } other {}}{hasFollowers, select, true {<Bold>{followersChannelCount, plural, =1 {one direct message} other {{followersChannelCount, number} direct messages}}</Bold>} other {}}.",
"playbooks.post_update.intro.no_followers_or_broadcast_channels": "This update will be saved to the overview page.",
"playbooks.post_update.label": "Update message",
"playbooks.post_update.label.also_mark_run_as_finished": "Also mark the run as finished",
"playbooks.post_update.label.next_update": "Timer for next update",
"playbooks.post_update.option.1_day": "1 day",
"playbooks.post_update.option.1_hour": "1 hour",
"playbooks.post_update.option.15_minutes": "15 minutes",
"playbooks.post_update.option.30_minutes": "30 minutes",
"playbooks.post_update.option.4_hours": "4 hours",
"playbooks.post_update.option.7_days": "7 days",
"playbooks.post_update.placeholder": "Enter your update message",
"playbooks.post_update.post.button": "Post",
"playbooks.post_update.title": "Post update",
"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.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.ok": "OK",
"playbooks.retrospective_not_available.title": "Playbooks Run Retrospective not available", "playbooks.retrospective_not_available.title": "Playbooks Run Retrospective not available",

View file

@ -1174,6 +1174,7 @@ class TestHelperSingleton {
checklists, checklists,
update_at: Date.now() + i, update_at: Date.now() + i,
items_order: checklists.map((checklist) => checklist.id), items_order: checklists.map((checklist) => checklist.id),
status_update_broadcast_channels_enabled: false,
}); });
} }
return playbookRuns; return playbookRuns;