[MM-66419] Playbooks on mobile UX fixes (#9250)
* [MM-66419] Playbooks on mobile UX fixes * Address UX feedback
This commit is contained in:
parent
72b97f60f5
commit
db154996f4
14 changed files with 88 additions and 41 deletions
|
|
@ -219,6 +219,7 @@ function AutoCompleteSelector({
|
|||
</Text>
|
||||
<CompassIcon
|
||||
name='chevron-down'
|
||||
size={20}
|
||||
color={changeOpacity(theme.centerChannelColor, 0.5)}
|
||||
/>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TabDefinition<TabsNames>> = [
|
|||
},
|
||||
];
|
||||
|
||||
const itemSeparatorStyle = {
|
||||
height: 12,
|
||||
};
|
||||
|
||||
const ItemSeparator = () => {
|
||||
return <View style={itemSeparatorStyle}/>;
|
||||
};
|
||||
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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: <FriendlyDate value={timestamp}/>};
|
||||
const values = useMemo(() => ({time: (
|
||||
<FriendlyDate
|
||||
style={styles.bold}
|
||||
value={timestamp}
|
||||
/>
|
||||
)}), [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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -140,8 +140,8 @@ const PlaybookRuns = ({
|
|||
}, []);
|
||||
|
||||
const startANewRun = useCallback(() => {
|
||||
goToSelectPlaybook(intl, theme);
|
||||
}, [intl, theme]);
|
||||
goToSelectPlaybook(intl, theme, channelId);
|
||||
}, [intl, theme, channelId]);
|
||||
|
||||
let content = (<EmptyState tab={activeTab}/>);
|
||||
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'
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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<KeyboardAwareScrollView>(null);
|
||||
useAvoidKeyboard(keyboardAwareRef);
|
||||
|
||||
const [followersCount, setFollowersCount] = useState<number>(0);
|
||||
const [broadcastChannelCount, setBroadcastChannelCount] = useState<number>(0);
|
||||
|
||||
|
|
@ -272,7 +280,12 @@ const PostUpdate = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<KeyboardAwareScrollView
|
||||
contentContainerStyle={styles.container}
|
||||
ref={keyboardAwareRef}
|
||||
nativeID={SecurityManager.getShieldScreenId(componentId, false, true)}
|
||||
style={styles.flex}
|
||||
>
|
||||
<Text style={styles.introMessage}>{introMessage}</Text>
|
||||
<FloatingTextInput
|
||||
label={intl.formatMessage({id: 'playbooks.post_update.label', defaultMessage: 'Update message'})}
|
||||
|
|
@ -281,6 +294,7 @@ const PostUpdate = ({
|
|||
onChangeText={onChangeText}
|
||||
theme={theme}
|
||||
multiline={true}
|
||||
multilineInputHeight={300}
|
||||
/>
|
||||
<FloatingAutocompleteSelector
|
||||
options={dialogOptions}
|
||||
|
|
@ -296,7 +310,7 @@ const PostUpdate = ({
|
|||
selected={alsoMarkRunAsFinished}
|
||||
type='toggle'
|
||||
/>
|
||||
</View>
|
||||
</KeyboardAwareScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export type Props = {
|
|||
currentUserId: string;
|
||||
componentId: AvailableScreens;
|
||||
playbooksUsedInChannel: Set<string>;
|
||||
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 (
|
||||
<Loading
|
||||
color={theme.buttonBg}
|
||||
|
|
@ -214,8 +225,8 @@ function SelectPlaybook({
|
|||
}, [intl, serverUrl]);
|
||||
|
||||
const onPress = useCallback((playbook: Playbook) => {
|
||||
goToStartARun(intl, theme, playbook, onRunCreated);
|
||||
}, [intl, onRunCreated, theme]);
|
||||
goToStartARun(intl, theme, playbook, onRunCreated, channelId);
|
||||
}, [intl, onRunCreated, theme, channelId]);
|
||||
|
||||
const renderItem = useCallback(({item}: ListRenderItemInfo<Playbook>) => {
|
||||
return (
|
||||
|
|
@ -242,9 +253,11 @@ function SelectPlaybook({
|
|||
|
||||
const renderSectionHeader = useCallback(({section}: {section: SectionListData<Playbook, DefaultSectionT>}) => {
|
||||
return (
|
||||
<Text style={style.sectionHeader}>{section.title}</Text>
|
||||
<View style={style.sectionHeaderContainer}>
|
||||
<Text style={style.sectionHeader}>{section.title}</Text>
|
||||
</View>
|
||||
);
|
||||
}, [style.sectionHeader]);
|
||||
}, [style]);
|
||||
|
||||
const sections: Array<SectionListData<Playbook, DefaultSectionT>> = useMemo(() => {
|
||||
type PlaybookSections = {
|
||||
|
|
|
|||
|
|
@ -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<ChannelOption>('existing');
|
||||
const [channelId, setChannelId] = useState<string | undefined>(undefined);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | undefined>(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'
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue