Skip to content

Commit

Permalink
Merge pull request #37361 from tienifr/feature/empty-state-ui-lhn
Browse files Browse the repository at this point in the history
Feature: Empty state UI for LHN
  • Loading branch information
roryabraham authored Apr 1, 2024
2 parents 6fbce73 + 2170e9d commit 732555a
Show file tree
Hide file tree
Showing 8 changed files with 173 additions and 51 deletions.
4 changes: 4 additions & 0 deletions .storybook/webpack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ const webpackConfig = ({config}: {config: Configuration}) => {
...custom.resolve.alias,
};

// We can ignore the "module not installed" warning from lottie-react-native
// because we are not using the library for JSON format of Lottie animations.
config.ignoreWarnings = [{module: new RegExp('node_modules/lottie-react-native/lib/module/LottieView/index.web.js')}];

// Necessary to overwrite the values in the existing DefinePlugin hardcoded to the Config staging values
const definePluginIndex = config.plugins.findIndex((plugin) => plugin instanceof DefinePlugin);
if (definePluginIndex !== -1 && config.plugins[definePluginIndex] instanceof DefinePlugin) {
Expand Down
100 changes: 73 additions & 27 deletions src/components/BlockingViews/BlockingView.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import React from 'react';
import type {ImageSourcePropType} from 'react-native';
import React, {useMemo} from 'react';
import type {ImageSourcePropType, StyleProp, ViewStyle, WebStyle} from 'react-native';
import {View} from 'react-native';
import type {SvgProps} from 'react-native-svg';
import type {MergeExclusive} from 'type-fest';
import AutoEmailLink from '@components/AutoEmailLink';
import Icon from '@components/Icon';
import Lottie from '@components/Lottie';
import type DotLottieAnimation from '@components/LottieAnimations/types';
import Text from '@components/Text';
import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
Expand All @@ -12,13 +15,7 @@ import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
import type {TranslationPaths} from '@src/languages/types';

type BlockingViewProps = {
/** Expensicon for the page */
icon: React.FC<SvgProps> | ImageSourcePropType;

/** Color for the icon (should be from theme) */
iconColor?: string;

type BaseBlockingViewProps = {
/** Title message below the icon */
title: string;

Expand All @@ -31,20 +28,46 @@ type BlockingViewProps = {
/** Whether we should show a link to navigate elsewhere */
shouldShowLink?: boolean;

/** Function to call when pressing the navigation link */
onLinkPress?: () => void;

/** Whether we should embed the link with subtitle */
shouldEmbedLinkWithSubtitle?: boolean;

/** Render custom subtitle */
CustomSubtitle?: React.ReactElement;
};

type BlockingViewIconProps = {
/** Expensicon for the page */
icon: React.FC<SvgProps> | ImageSourcePropType;

/** The custom icon width */
iconWidth?: number;

/** The custom icon height */
iconHeight?: number;

/** Function to call when pressing the navigation link */
onLinkPress?: () => void;
/** Color for the icon (should be from theme) */
iconColor?: string;
};

/** Whether we should embed the link with subtitle */
shouldEmbedLinkWithSubtitle?: boolean;
type BlockingViewAnimationProps = {
/** Animation for the page */
animation: DotLottieAnimation;

/** Style for the animation */
animationStyles?: StyleProp<ViewStyle>;

/** Style for the animation on web */
animationWebStyle?: WebStyle;
};

// This page requires either an icon or an animation, but not both
type BlockingViewProps = BaseBlockingViewProps & MergeExclusive<BlockingViewIconProps, BlockingViewAnimationProps>;

function BlockingView({
animation,
icon,
iconColor,
title,
Expand All @@ -55,11 +78,15 @@ function BlockingView({
iconHeight = variables.iconSizeSuperLarge,
onLinkPress = () => Navigation.dismissModal(),
shouldEmbedLinkWithSubtitle = false,
animationStyles = [],
animationWebStyle = {},
CustomSubtitle,
}: BlockingViewProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
function renderContent() {
return (

const subtitleText = useMemo(
() => (
<>
<AutoEmailLink
style={[styles.textAlignCenter]}
Expand All @@ -74,25 +101,44 @@ function BlockingView({
</TextLink>
) : null}
</>
),
[styles, subtitle, shouldShowLink, linkKey, onLinkPress, translate],
);

const subtitleContent = useMemo(() => {
if (CustomSubtitle) {
return CustomSubtitle;
}
return shouldEmbedLinkWithSubtitle ? (
<Text style={[styles.textAlignCenter]}>{subtitleText}</Text>
) : (
<View style={[styles.alignItemsCenter, styles.justifyContentCenter]}>{subtitleText}</View>
);
}
}, [styles, subtitleText, shouldEmbedLinkWithSubtitle, CustomSubtitle]);

return (
<View style={[styles.flex1, styles.alignItemsCenter, styles.justifyContentCenter, styles.ph10]}>
<Icon
src={icon}
fill={iconColor}
width={iconWidth}
height={iconHeight}
/>
{animation && (
<Lottie
source={animation}
loop
autoPlay
style={animationStyles}
webStyle={animationWebStyle}
/>
)}
{icon && (
<Icon
src={icon}
fill={iconColor}
width={iconWidth}
height={iconHeight}
/>
)}
<View>
<Text style={[styles.notFoundTextHeader]}>{title}</Text>

{shouldEmbedLinkWithSubtitle ? (
<Text style={[styles.textAlignCenter]}>{renderContent()}</Text>
) : (
<View style={[styles.alignItemsCenter, styles.justifyContentCenter]}>{renderContent()}</View>
)}
{subtitleContent}
</View>
</View>
);
Expand Down
89 changes: 73 additions & 16 deletions src/components/LHNOptionsList/LHNOptionsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,18 @@ import type {ReactElement} from 'react';
import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react';
import {StyleSheet, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import BlockingView from '@components/BlockingViews/BlockingView';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import LottieAnimations from '@components/LottieAnimations';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import usePermissions from '@hooks/usePermissions';
import usePrevious from '@hooks/usePrevious';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import variables from '@styles/variables';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -39,8 +47,12 @@ function LHNOptionsList({
const flashListRef = useRef<FlashList<string>>(null);
const route = useRoute();

const theme = useTheme();
const styles = useThemeStyles();
const {canUseViolations} = usePermissions();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
const shouldShowEmptyLHN = isSmallScreenWidth && data.length === 0;

// When the first item renders we want to call the onFirstItemRendered callback.
// At this point in time we know that the list is actually displaying items.
Expand All @@ -54,6 +66,40 @@ function LHNOptionsList({
onFirstItemRendered();
}, [onFirstItemRendered]);

const emptyLHNSubtitle = useMemo(
() => (
<View>
<Text
color={theme.placeholderText}
style={[styles.textAlignCenter]}
>
{translate('common.emptyLHN.subtitleText1')}
<Icon
src={Expensicons.MagnifyingGlass}
width={variables.emptyLHNIconWidth}
height={variables.emptyLHNIconHeight}
small
inline
fill={theme.icon}
additionalStyles={styles.alignItemsCenter}
/>
{translate('common.emptyLHN.subtitleText2')}
<Icon
src={Expensicons.Plus}
width={variables.emptyLHNIconWidth}
height={variables.emptyLHNIconHeight}
small
inline
fill={theme.icon}
additionalStyles={styles.alignItemsCenter}
/>
{translate('common.emptyLHN.subtitleText3')}
</Text>
</View>
),
[theme, styles.alignItemsCenter, styles.textAlignCenter, translate],
);

/**
* Function which renders a row in the list
*/
Expand Down Expand Up @@ -163,22 +209,33 @@ function LHNOptionsList({
}, [route, flashListRef, getScrollOffset]);

return (
<View style={style ?? styles.flex1}>
<FlashList
ref={flashListRef}
indicatorStyle="white"
keyboardShouldPersistTaps="always"
contentContainerStyle={StyleSheet.flatten(contentContainerStyles)}
data={data}
testID="lhn-options-list"
keyExtractor={keyExtractor}
renderItem={renderItem}
estimatedItemSize={optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight}
extraData={extraData}
showsVerticalScrollIndicator={false}
onLayout={onLayout}
onScroll={onScroll}
/>
<View style={[style ?? styles.flex1, shouldShowEmptyLHN ? styles.emptyLHNWrapper : undefined]}>
{shouldShowEmptyLHN ? (
<BlockingView
animation={LottieAnimations.Fireworks}
animationStyles={styles.emptyLHNAnimation}
animationWebStyle={styles.emptyLHNAnimation}
title={translate('common.emptyLHN.title')}
shouldShowLink={false}
CustomSubtitle={emptyLHNSubtitle}
/>
) : (
<FlashList
ref={flashListRef}
indicatorStyle="white"
keyboardShouldPersistTaps="always"
contentContainerStyle={StyleSheet.flatten(contentContainerStyles)}
data={data}
testID="lhn-options-list"
keyExtractor={keyExtractor}
renderItem={renderItem}
estimatedItemSize={optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight}
extraData={extraData}
showsVerticalScrollIndicator={false}
onLayout={onLayout}
onScroll={onScroll}
/>
)}
</View>
);
}
Expand Down
6 changes: 6 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,12 @@ export default {
member: 'Member',
role: 'Role',
currency: 'Currency',
emptyLHN: {
title: 'Woohoo! All caught up.',
subtitleText1: 'Find a chat using the',
subtitleText2: 'button above, or create something using the',
subtitleText3: 'button below.',
},
},
location: {
useCurrent: 'Use current location',
Expand Down
6 changes: 6 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,12 @@ export default {
member: 'Miembro',
role: 'Role',
currency: 'Divisa',
emptyLHN: {
title: 'Woohoo! Todo al día.',
subtitleText1: 'Encuentra un chat usando el botón',
subtitleText2: 'o crea algo usando el botón',
subtitleText3: '.',
},
},
location: {
useCurrent: 'Usar ubicación actual',
Expand Down
8 changes: 0 additions & 8 deletions src/libs/SidebarUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,6 @@ function getOrderedReportIDs(
});
});

if (reportsToDisplay.length === 0) {
// Display Concierge chat report when there is no report to be displayed
const conciergeChatReport = allReportsDictValues.find(ReportUtils.isConciergeChatReport);
if (conciergeChatReport) {
reportsToDisplay.push(conciergeChatReport);
}
}

// The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
// 1. Pinned/GBR - Always sorted by reportDisplayName
// 2. Drafts - Always sorted by reportDisplayName
Expand Down
9 changes: 9 additions & 0 deletions src/styles/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3212,6 +3212,15 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
},

emptyLHNWrapper: {
marginBottom: variables.bottomTabHeight,
},

emptyLHNAnimation: {
width: 180,
height: 180,
},

locationErrorLinkText: {
textAlignVertical: 'center',
fontSize: variables.fontSizeLabel,
Expand Down
2 changes: 2 additions & 0 deletions src/styles/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ export default {
avatarChatSpacing: 12,
chatInputSpacing: 52, // 40 + avatarChatSpacing
borderTopWidth: 1,
emptyLHNIconWidth: 24, // iconSizeSmall + 4*2 horizontal margin
emptyLHNIconHeight: 16,
emptyWorkspaceIconWidth: 84,
emptyWorkspaceIconHeight: 84,
modalTopIconWidth: 200,
Expand Down

0 comments on commit 732555a

Please sign in to comment.