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

View file

@ -189,7 +189,7 @@ const FloatingInputContainer = ({
paddingHorizontal: focusedLabel || inputText ? 4 : 0,
color,
};
});
}, [styles, theme, focusedLabel, hasValue, shouldShowError, positions]);
return (
<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 {
const client = NetworkManager.getClient(serverUrl);
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) {
logDebug('error on fetchPlaybookRun', getFullErrorMessage(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) => {
try {
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) => {
try {
const client = NetworkManager.getClient(serverUrl);

View file

@ -10,10 +10,13 @@ export interface ClientPlaybooksMix {
// Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
fetchPlaybookRunMetadata: (id: string) => Promise<PlaybookRunMetadata>;
setOwner: (playbookRunId: string, ownerId: string) => Promise<void>;
// Run Management
// finishRun: (playbookRunId: string) => Promise<any>;
finishRun: (playbookRunId: string) => Promise<void>;
postStatusUpdate: (playbookRunID: string, payload: PostStatusUpdatePayload, ids: PostStatusUpdateIds) => Promise<void>;
// Checklist Management
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) => {
const data = await this.doFetch(
`${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
setChecklistItemState = async (playbookRunID: string, checklistNum: number, itemNum: number, newState: ChecklistItemState) => {
await this.doFetch(

View file

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

View file

@ -10,6 +10,7 @@ import {render} from '@test/intl-test-helper';
import EditCommand from './edit_command';
import PlaybookRun from './playbook_run';
import PlaybookRuns from './playbooks_runs';
import PostUpdate from './post_update';
import SelectDate from './select_date';
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.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', () => {
beforeEach(() => {
jest.clearAllMocks();

View file

@ -12,6 +12,8 @@ export function loadPlaybooksScreen(screenName: string | number) {
return withServerDatabase(require('@playbooks/screens/playbook_run').default);
case Screens.PLAYBOOK_EDIT_COMMAND:
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:
return withServerDatabase(require('@playbooks/screens/select_user').default);
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}, {});
}
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 {
return {
topBar: {

View file

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

View file

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

View file

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

View file

@ -54,6 +54,7 @@ type RunMetricData = {
type StatusPost = {
id: string;
create_at: number;
delete_at: number;
}
type TimelineEvent = {
@ -135,4 +136,14 @@ type PlaybookRun = {
metrics_data: RunMetricData[];
update_at: number;
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;
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});
}
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}) {
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 totalMinutes = (totalHours * 60) + (minutes || 0);
const totalSeconds = (totalMinutes * 60) + (seconds || 0);
return totalSeconds * 1000;
return totalSeconds;
}
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_title": "Run Participants",
"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_finished": "Run finished {time}",
"playbooks.playbook_run.status_update_overdue": "Status update overdue {time}",
"playbooks.playbook_run.tasks": "Tasks",
"playbooks.playbook_run.title": "Playbook run",
"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.ok": "OK",
"playbooks.retrospective_not_available.title": "Playbooks Run Retrospective not available",

View file

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