From db154996f4a58d9e40b73c5bcb5b887075151ab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Espino=20Garc=C3=ADa?= Date: Wed, 5 Nov 2025 11:00:48 +0100 Subject: [PATCH] [MM-66419] Playbooks on mobile UX fixes (#9250) * [MM-66419] Playbooks on mobile UX fixes * Address UX feedback --- .../floating_autocomplete_selector.tsx | 1 + .../floating_input_container.tsx | 2 +- .../floating_text_input_label.tsx | 4 ++-- app/components/floating_input/utils.test.ts | 7 ++---- app/components/floating_input/utils.ts | 2 +- app/products/playbooks/screens/navigation.ts | 7 +++--- .../participant_playbooks.tsx | 11 +++++++-- .../status_update_indicator.test.tsx | 4 ++-- .../playbook_run/status_update_indicator.tsx | 21 ++++++++++++---- .../screens/playbooks_runs/playbook_runs.tsx | 5 ++-- .../screens/post_update/post_update.tsx | 24 +++++++++++++++---- .../select_playbook/select_playbook.tsx | 23 ++++++++++++++---- .../screens/start_a_run/start_a_run.tsx | 12 ++++++---- assets/base/i18n/en.json | 6 ++--- 14 files changed, 88 insertions(+), 41 deletions(-) diff --git a/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx b/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx index 870cc285a..cacbacfb0 100644 --- a/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx +++ b/app/components/floating_input/floating_autocomplete_selector/floating_autocomplete_selector.tsx @@ -219,6 +219,7 @@ function AutoCompleteSelector({ diff --git a/app/components/floating_input/floating_input_container.tsx b/app/components/floating_input/floating_input_container.tsx index 6e6edb2f8..f0792f845 100644 --- a/app/components/floating_input/floating_input_container.tsx +++ b/app/components/floating_input/floating_input_container.tsx @@ -123,7 +123,7 @@ const FloatingInputContainer = ({ testID, }: Props) => { const styles = getStyleSheet(theme); - const positions = useMemo(() => getLabelPositions(styles.textInput, styles.label, styles.smallLabel), [styles]); + const positions = useMemo(() => getLabelPositions(styles.textInput, styles.bigLabel, styles.smallLabel), [styles]); const errorIcon = 'alert-outline'; const shouldShowError = !focused && error; diff --git a/app/components/floating_input/floating_text_input_label.tsx b/app/components/floating_input/floating_text_input_label.tsx index c5e9b6a43..0e49aac40 100644 --- a/app/components/floating_input/floating_text_input_label.tsx +++ b/app/components/floating_input/floating_text_input_label.tsx @@ -7,6 +7,7 @@ import React, {useState, useRef, useImperativeHandle, forwardRef, useMemo, useCa import {type LayoutChangeEvent, type NativeSyntheticEvent, type StyleProp, type TargetedEvent, TextInput, type TextInputFocusEventData, type TextInputProps, type TextStyle} from 'react-native'; import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme'; +import {typography} from '@utils/typography'; import FloatingInputContainer from './floating_input_container'; import {onExecution} from './utils'; @@ -22,11 +23,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ paddingTop: 0, paddingBottom: 0, flexDirection: 'row', - fontFamily: 'OpenSans', - fontSize: 16, color: theme.centerChannelColor, borderColor: changeOpacity(theme.centerChannelColor, 0.16), borderRadius: 4, + ...typography('Body', 200), }, })); diff --git a/app/components/floating_input/utils.test.ts b/app/components/floating_input/utils.test.ts index 417e9f63d..6c4ec87ff 100644 --- a/app/components/floating_input/utils.test.ts +++ b/app/components/floating_input/utils.test.ts @@ -9,14 +9,13 @@ describe('getLabelPositions', () => { paddingTop: 10, paddingBottom: 10, height: 50, - fontSize: 14, padding: 20, }; const labelStyle = {fontSize: 15}; const smallLabelStyle = {fontSize: 11}; const result = getLabelPositions(style, labelStyle, smallLabelStyle); - expect(result).toEqual([24.4, -6.5]); + expect(result).toEqual([25, -6.5]); }); test('should return correct positions when label and smallLabels styles are missing', () => { @@ -24,14 +23,13 @@ describe('getLabelPositions', () => { paddingTop: 15, paddingBottom: 15, height: 50, - fontSize: 14, padding: 25, }; const labelStyle = {}; const smallLabelStyle = {}; const result = getLabelPositions(style, labelStyle, smallLabelStyle); - expect(result).toEqual([23.8, -6.5]); + expect(result).toEqual([23.2, -6.5]); }); test('should return correct positions when all values are empty are provided', () => { @@ -49,7 +47,6 @@ describe('getLabelPositions', () => { paddingTop: 0, paddingBottom: 0, height: 0, - fontSize: 0, padding: 0, }; const labelStyle = {fontSize: 0}; diff --git a/app/components/floating_input/utils.ts b/app/components/floating_input/utils.ts index 14c993ea5..e60c74b8d 100644 --- a/app/components/floating_input/utils.ts +++ b/app/components/floating_input/utils.ts @@ -17,7 +17,7 @@ export const getLabelPositions = (style: TextStyle, labelStyle: TextStyle, small const bottom: number = style.paddingBottom as number || 0; const height: number = (style.height as number || (top + bottom) || style.padding as number) || 0; - const textInputFontSize = style.fontSize || 13; + const textInputFontSize = labelStyle.fontSize || 13; const labelFontSize = labelStyle.fontSize || 16; const smallLabelFontSize = smallLabelStyle.fontSize || 10; const fontSizeDiff = textInputFontSize - labelFontSize; diff --git a/app/products/playbooks/screens/navigation.ts b/app/products/playbooks/screens/navigation.ts index bec4b72fd..0c9dc06af 100644 --- a/app/products/playbooks/screens/navigation.ts +++ b/app/products/playbooks/screens/navigation.ts @@ -120,9 +120,10 @@ export async function goToSelectDate( export async function goToSelectPlaybook( intl: IntlShape, theme: Theme, + channelId?: string, ) { const title = intl.formatMessage({id: 'playbooks.select_playbook.title', defaultMessage: 'Start a run'}); - goToScreen(Screens.PLAYBOOKS_SELECT_PLAYBOOK, title, {}, { + goToScreen(Screens.PLAYBOOKS_SELECT_PLAYBOOK, title, {channelId}, { topBar: { subtitle: { text: intl.formatMessage({id: 'playbooks.select_playbook.subtitle', defaultMessage: 'Select a playbook'}), @@ -132,10 +133,10 @@ export async function goToSelectPlaybook( }); } -export async function goToStartARun(intl: IntlShape, theme: Theme, playbook: Playbook, onRunCreated: (run: PlaybookRun) => void) { +export async function goToStartARun(intl: IntlShape, theme: Theme, playbook: Playbook, onRunCreated: (run: PlaybookRun) => void, channelId?: string) { 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}, { + goToScreen(Screens.PLAYBOOKS_START_A_RUN, title, {playbook, onRunCreated, channelId}, { topBar: { subtitle: { text: subtitle, diff --git a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx index 078e018d9..bb6b5c6c8 100644 --- a/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx +++ b/app/products/playbooks/screens/participant_playbooks/participant_playbooks.tsx @@ -7,7 +7,6 @@ import {defineMessages, useIntl} from 'react-intl'; import {View} from 'react-native'; import Loading from '@components/loading'; -import MenuDivider from '@components/menu_divider'; import SectionNotice from '@components/section_notice'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; @@ -83,6 +82,14 @@ const tabs: Array> = [ }, ]; +const itemSeparatorStyle = { + height: 12, +}; + +const ItemSeparator = () => { + return ; +}; + const ParticipantPlaybooks = ({ currentUserId, componentId, @@ -202,7 +209,7 @@ const ParticipantPlaybooks = ({ data={data} renderItem={renderItem} contentContainerStyle={styles.container} - ItemSeparatorComponent={MenuDivider} + ItemSeparatorComponent={ItemSeparator} estimatedItemSize={CARD_HEIGHT} onEndReached={loadMore} onEndReachedThreshold={0.1} diff --git a/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx b/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx index 77e021b61..e5f9715d0 100644 --- a/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx +++ b/app/products/playbooks/screens/playbook_run/status_update_indicator.test.tsx @@ -37,7 +37,7 @@ describe('StatusUpdateIndicator', () => { />, ); - const text = getByText(/Status update due/); + const text = getByText(/Update due/); expect(text).toHaveStyle({color: changeOpacity(Preferences.THEMES.denim.centerChannelColor, 0.72)}); const icon = getByTestId('compass-icon'); @@ -55,7 +55,7 @@ describe('StatusUpdateIndicator', () => { />, ); - const text = getByText(/Status update overdue/); + const text = getByText(/Update overdue/); expect(text).toHaveStyle({color: Preferences.THEMES.denim.dndIndicator}); const icon = getByTestId('compass-icon'); diff --git a/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx b/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx index e956100ad..d0c4ab7ff 100644 --- a/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx +++ b/app/products/playbooks/screens/playbook_run/status_update_indicator.tsx @@ -55,20 +55,23 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => ({ overdueText: { color: theme.dndIndicator, }, + bold: { + ...typography('Body', 300, 'SemiBold'), + }, })); const messages = defineMessages({ updateOverdue: { id: 'playbooks.playbook_run.status_update_overdue', - defaultMessage: 'Status update overdue {time}', + defaultMessage: 'Update overdue\n{time}', }, updateDue: { id: 'playbooks.playbook_run.status_update_due', - defaultMessage: 'Status update due {time}', + defaultMessage: 'Update due\n{time}', }, finished: { id: 'playbooks.playbook_run.status_update_finished', - defaultMessage: 'Run finished {time}', + defaultMessage: 'Run finished\n{time}', }, update: { id: 'playbooks.playbook_run.status_update', @@ -86,13 +89,20 @@ const StatusUpdateIndicator = ({ const styles = getStyleSheet(theme); const intl = useIntl(); - const values = {time: }; + const values = useMemo(() => ({time: ( + + )}), [timestamp, styles]); + const readOnly = !isParticipant || isFinished; + const isOverdue = timestamp < Date.now(); let message = messages.updateDue; if (isFinished) { message = messages.finished; - } else if (timestamp < Date.now()) { + } else if (isOverdue) { message = messages.updateOverdue; } @@ -137,6 +147,7 @@ const StatusUpdateIndicator = ({ theme={theme} size='lg' disabled={readOnly} + isDestructive={isOverdue && !isFinished} /> ); diff --git a/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx b/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx index 7f0b9acd3..2b9aff049 100644 --- a/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx +++ b/app/products/playbooks/screens/playbooks_runs/playbook_runs.tsx @@ -140,8 +140,8 @@ const PlaybookRuns = ({ }, []); const startANewRun = useCallback(() => { - goToSelectPlaybook(intl, theme); - }, [intl, theme]); + goToSelectPlaybook(intl, theme, channelId); + }, [intl, theme, channelId]); let content = (); if (!isEmpty) { @@ -162,6 +162,7 @@ const PlaybookRuns = ({ text={intl.formatMessage({id: 'playbooks.runs.start_a_new_run', defaultMessage: 'Start a new run'})} size='lg' theme={theme} + iconName='play-outline' /> diff --git a/app/products/playbooks/screens/post_update/post_update.tsx b/app/products/playbooks/screens/post_update/post_update.tsx index f4c28746d..4991e4850 100644 --- a/app/products/playbooks/screens/post_update/post_update.tsx +++ b/app/products/playbooks/screens/post_update/post_update.tsx @@ -1,9 +1,10 @@ // 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 {type ComponentProps, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import {Alert, Keyboard, Text, View} from 'react-native'; +import {Alert, Keyboard, Text} from 'react-native'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-aware-scroll-view'; import {getPosts} from '@actions/local/post'; import FloatingAutocompleteSelector from '@components/floating_input/floating_autocomplete_selector'; @@ -13,7 +14,9 @@ import OptionItem from '@components/option_item'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import useAndroidHardwareBackHandler from '@hooks/android_back_handler'; +import {useAvoidKeyboard} from '@hooks/device'; import useNavButtonPressed from '@hooks/navigation_button_pressed'; +import SecurityManager from '@managers/security_manager'; import {fetchPlaybookRun, fetchPlaybookRunMetadata, postStatusUpdate} from '@playbooks/actions/remote/runs'; import {buildNavigationButton, popTopScreen, setButtons} from '@screens/navigation'; import {toSeconds} from '@utils/datetime'; @@ -35,7 +38,6 @@ type Props = { const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ container: { - flex: 1, padding: 20, gap: 12, }, @@ -46,6 +48,9 @@ const getStyles = makeStyleSheetFromTheme((theme: Theme) => ({ introMessageBold: { ...typography('Body', 200, 'SemiBold'), }, + flex: { + flex: 1, + }, })); const SAVE_BUTTON_ID = 'save-post-update'; @@ -84,6 +89,9 @@ const PostUpdate = ({ const [alsoMarkRunAsFinished, setAlsoMarkRunAsFinished] = useState(false); const [canSave, setCanSave] = useState(false); + const keyboardAwareRef = useRef(null); + useAvoidKeyboard(keyboardAwareRef); + const [followersCount, setFollowersCount] = useState(0); const [broadcastChannelCount, setBroadcastChannelCount] = useState(0); @@ -272,7 +280,12 @@ const PostUpdate = ({ } return ( - + {introMessage} - + ); }; diff --git a/app/products/playbooks/screens/select_playbook/select_playbook.tsx b/app/products/playbooks/screens/select_playbook/select_playbook.tsx index ba4dfd945..25b59e76a 100644 --- a/app/products/playbooks/screens/select_playbook/select_playbook.tsx +++ b/app/products/playbooks/screens/select_playbook/select_playbook.tsx @@ -39,6 +39,7 @@ export type Props = { currentUserId: string; componentId: AvailableScreens; playbooksUsedInChannel: Set; + channelId?: string; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -81,6 +82,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { flex: 0, backgroundColor: changeOpacity(theme.centerChannelColor, 0.1), }, + sectionHeaderContainer: { + backgroundColor: theme.centerChannelBg, + paddingVertical: 8, + }, sectionHeader: { color: changeOpacity(theme.centerChannelColor, 0.56), ...typography('Body', 75, 'SemiBold'), @@ -96,6 +101,7 @@ function SelectPlaybook({ currentUserId, componentId, playbooksUsedInChannel, + channelId, }: Props) { const serverUrl = useServerUrl(); const theme = useTheme(); @@ -185,7 +191,12 @@ function SelectPlaybook({ }, []); const renderNoResults = useCallback((): JSX.Element | null => { - if (searching || (loading && page.current === -1)) { + if (loading && page.current === -1) { + // Already handled by the loading component + return null; + } + + if (searching) { return ( { - goToStartARun(intl, theme, playbook, onRunCreated); - }, [intl, onRunCreated, theme]); + goToStartARun(intl, theme, playbook, onRunCreated, channelId); + }, [intl, onRunCreated, theme, channelId]); const renderItem = useCallback(({item}: ListRenderItemInfo) => { return ( @@ -242,9 +253,11 @@ function SelectPlaybook({ const renderSectionHeader = useCallback(({section}: {section: SectionListData}) => { return ( - {section.title} + + {section.title} + ); - }, [style.sectionHeader]); + }, [style]); const sections: Array> = useMemo(() => { type PlaybookSections = { diff --git a/app/products/playbooks/screens/start_a_run/start_a_run.tsx b/app/products/playbooks/screens/start_a_run/start_a_run.tsx index 6e53e7e0c..f0f80b084 100644 --- a/app/products/playbooks/screens/start_a_run/start_a_run.tsx +++ b/app/products/playbooks/screens/start_a_run/start_a_run.tsx @@ -32,6 +32,7 @@ export type Props = { currentUserId: string; currentTeamId: string; onRunCreated: (run: PlaybookRun) => void; + channelId?: string; } const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { @@ -86,6 +87,7 @@ function StartARun({ currentUserId, currentTeamId, onRunCreated, + channelId, }: Props) { const theme = useTheme(); const intl = useIntl(); @@ -105,7 +107,7 @@ function StartARun({ return ''; }); const [channelOption, setChannelOption] = useState('existing'); - const [channelId, setChannelId] = useState(undefined); + const [selectedChannelId, setSelectedChannelId] = useState(channelId); const [createPublicChannel, setCreatePublicChannel] = useState(false); const canSave = Boolean(runName.trim()); @@ -115,7 +117,7 @@ function StartARun({ return; } - const res = await createPlaybookRun(serverUrl, playbook.id, currentUserId, currentTeamId, runName.trim(), runDescription.trim(), channelId, channelOption === 'new' ? createPublicChannel : undefined); + const res = await createPlaybookRun(serverUrl, playbook.id, currentUserId, currentTeamId, runName.trim(), runDescription.trim(), selectedChannelId, channelOption === 'new' ? createPublicChannel : undefined); if (res.error || !res.data) { logDebug('error on createPlaybookRun', getFullErrorMessage(res.error)); showPlaybookErrorSnackbar(); @@ -123,7 +125,7 @@ function StartARun({ } await popTopScreen(componentId); onRunCreated(res.data); - }, [runName, serverUrl, playbook.id, currentUserId, currentTeamId, runDescription, channelId, channelOption, createPublicChannel, componentId, onRunCreated]); + }, [runName, serverUrl, playbook.id, currentUserId, currentTeamId, runDescription, selectedChannelId, channelOption, createPublicChannel, componentId, onRunCreated]); useEffect(() => { async function asyncWrapper() { @@ -174,7 +176,7 @@ function StartARun({ logDebug('on channel selected returned undefined, this should never happen'); return; } - setChannelId(value.value); + setSelectedChannelId(value.value); }, []); return ( @@ -232,7 +234,7 @@ function StartARun({ defaultMessage: 'Channel', })} dataSource='channels' - selected={channelId} + selected={selectedChannelId} onSelected={onChannelSelected} testID='start_run.existing_channel_selector' /> diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 8a14ba84b..ea48506c3 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -1040,9 +1040,9 @@ "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.status_update_due": "Update due\n{time}", + "playbooks.playbook_run.status_update_finished": "Run finished\n{time}", + "playbooks.playbook_run.status_update_overdue": "Update overdue\n{time}", "playbooks.playbook_run.tasks": "Tasks", "playbooks.playbook_run.title": "Playbook run", "playbooks.playbooks_runs.title": "Playbook runs",