Skip to content

Commit

Permalink
Merge pull request #40903 from software-mansion-labs/kicu/search-v1/s…
Browse files Browse the repository at this point in the history
…earch-widget

[Search v1] Create Search widget
  • Loading branch information
luacmartins authored Apr 26, 2024
2 parents 2487ab7 + 6665e29 commit c50de77
Show file tree
Hide file tree
Showing 19 changed files with 221 additions and 144 deletions.
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ const ONYXKEYS = {

/** This is deprecated, but needed for a migration, so we still need to include it here so that it will be initialized in Onyx.init */
DEPRECATED_POLICY_MEMBER_LIST: 'policyMemberList_',

// Search Page related
SNAPSHOT: 'snapshot_',
},

/** List of Form ids */
Expand Down Expand Up @@ -563,6 +566,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep;
[ONYXKEYS.COLLECTION.POLICY_JOIN_MEMBER]: OnyxTypes.PolicyJoinMember;
[ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS]: OnyxTypes.PolicyConnectionSyncProgress;
[ONYXKEYS.COLLECTION.SNAPSHOT]: OnyxTypes.SearchResults;
};

type OnyxValuesMapping = {
Expand Down
93 changes: 36 additions & 57 deletions src/components/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,68 +1,47 @@
import React from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import {PressableWithFeedback} from './Pressable';
import Text from './Text';
import Tooltip from './Tooltip';
import React, {useEffect} from 'react';
import {useOnyx} from 'react-native-onyx';
import useNetwork from '@hooks/useNetwork';
import * as SearchActions from '@libs/actions/Search';
import * as SearchUtils from '@libs/SearchUtils';
import EmptySearchView from '@pages/Search/EmptySearchView';
import ONYXKEYS from '@src/ONYXKEYS';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import TableListItemSkeleton from './Skeletons/TableListItemSkeleton';

type SearchProps = {
// Callback fired when component is pressed
onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
query: string;
};

// Text explaining what the user can search for
placeholder?: string;
function Search({query}: SearchProps) {
const {isOffline} = useNetwork();
const hash = SearchUtils.getQueryHash(query);
const [searchResults, searchResultsMeta] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`);

// Text showing up in a tooltip when component is hovered
tooltip?: string;
useEffect(() => {
SearchActions.search(query);
}, [query]);

// Styles to apply on the outer element
style?: StyleProp<ViewStyle>;
const isLoading = !isOffline && isLoadingOnyxValue(searchResultsMeta);
const shouldShowEmptyState = isEmptyObject(searchResults);

/** Styles to apply to the outermost element */
containerStyle?: StyleProp<ViewStyle>;
};
if (isLoading) {
return <TableListItemSkeleton shouldAnimate />;
}

if (shouldShowEmptyState) {
return <EmptySearchView />;
}

function Search({onPress, placeholder, tooltip, style, containerStyle}: SearchProps) {
const styles = useThemeStyles();
const theme = useTheme();
const {translate} = useLocalize();
const ListItem = SearchUtils.getListItem();

return (
<View style={containerStyle}>
<Tooltip text={tooltip ?? translate('common.search')}>
<PressableWithFeedback
accessibilityLabel={tooltip ?? translate('common.search')}
role={CONST.ROLE.BUTTON}
onPress={onPress}
style={styles.searchPressable}
>
{({hovered}) => (
<View style={[styles.searchContainer, hovered && styles.searchContainerHovered, style]}>
<Icon
src={Expensicons.MagnifyingGlass}
width={variables.iconSizeSmall}
height={variables.iconSizeSmall}
fill={theme.icon}
/>
<Text
style={styles.searchInputStyle}
numberOfLines={1}
>
{placeholder ?? translate('common.searchWithThreeDots')}
</Text>
</View>
)}
</PressableWithFeedback>
</Tooltip>
</View>
);
// This will be updated with the proper List component in another PR
return SearchUtils.getSections(searchResults.data).map((item) => (
<ListItem
key={item.transactionID}
item={item}
/>
));
}

Search.displayName = 'Search';
Expand Down
18 changes: 18 additions & 0 deletions src/components/SelectionList/TemporaryTransactionListItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import React from 'react';
import {View} from 'react-native';
import Text from '@components/Text';
import useThemeStyles from '@hooks/useThemeStyles';
import type {SearchTransaction} from '@src/types/onyx/SearchResults';

// NOTE: This is a completely temporary mock item so that something can be displayed in SearchWidget
// This should be removed and implement properly in: https://github.com/Expensify/App/issues/39877
function TransactionListItem({item}: {item: SearchTransaction}) {
const styles = useThemeStyles();
return (
<View style={[styles.pt8]}>
<Text>Item: {item.transactionID}</Text>
</View>
);
}

export default TransactionListItem;
6 changes: 6 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2461,6 +2461,12 @@ export default {
},
search: {
resultsAreLimited: 'Search results are limited.',
searchResults: {
emptyResults: {
title: 'Nothing to show',
subtitle: 'Try creating something using the green + button.',
},
},
},
genericErrorPage: {
title: 'Uh-oh, something went wrong!',
Expand Down
6 changes: 6 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2492,6 +2492,12 @@ export default {
},
search: {
resultsAreLimited: 'Los resultados de búsqueda están limitados.',
searchResults: {
emptyResults: {
title: 'No hay nada que ver aquí',
subtitle: 'Por favor intenta crear algo usando el botón verde.',
},
},
},
genericErrorPage: {
title: '¡Oh-oh, algo salió mal!',
Expand Down
6 changes: 6 additions & 0 deletions src/libs/API/parameters/Search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type SearchParams = {
query: string;
hash: number;
};

export default SearchParams;
1 change: 1 addition & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,4 @@ export type {default as ShareTrackedExpenseParams} from './ShareTrackedExpensePa
export type {default as CategorizeTrackedExpenseParams} from './CategorizeTrackedExpenseParams';
export type {default as LeavePolicyParams} from './LeavePolicyParams';
export type {default as OpenPolicyAccountingPageParams} from './OpenPolicyAccountingPageParams';
export type {default as SearchParams} from './Search';
2 changes: 2 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ const READ_COMMANDS = {
OPEN_POLICY_DISTANCE_RATES_PAGE: 'OpenPolicyDistanceRatesPage',
OPEN_POLICY_MORE_FEATURES_PAGE: 'OpenPolicyMoreFeaturesPage',
OPEN_POLICY_ACCOUNTING_PAGE: 'OpenPolicyAccountingPage',
SEARCH: 'Search',
} as const;

type ReadCommand = ValueOf<typeof READ_COMMANDS>;
Expand Down Expand Up @@ -510,6 +511,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.OPEN_POLICY_DISTANCE_RATES_PAGE]: Parameters.OpenPolicyDistanceRatesPageParams;
[READ_COMMANDS.OPEN_POLICY_MORE_FEATURES_PAGE]: Parameters.OpenPolicyMoreFeaturesPageParams;
[READ_COMMANDS.OPEN_POLICY_ACCOUNTING_PAGE]: Parameters.OpenPolicyAccountingPageParams;
[READ_COMMANDS.SEARCH]: Parameters.SearchParams;
};

const SIDE_EFFECT_REQUEST_COMMANDS = {
Expand Down
36 changes: 36 additions & 0 deletions src/libs/SearchUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import TransactionListItem from '@components/SelectionList/TemporaryTransactionListItem';
import ONYXKEYS from '@src/ONYXKEYS';
import type * as OnyxTypes from '@src/types/onyx';
import type {SearchTransaction} from '@src/types/onyx/SearchResults';
import * as UserUtils from './UserUtils';

function getTransactionsSections(data: OnyxTypes.SearchResults['data']): SearchTransaction[] {
return Object.entries(data)
.filter(([key]) => key.startsWith(ONYXKEYS.COLLECTION.TRANSACTION))
.map(([, value]) => value);
}

const searchTypeToItemMap = {
transaction: {
listItem: TransactionListItem,
getSections: getTransactionsSections,
},
};

/**
* TODO: in future make this function generic and return specific item component based on type
* For now only 1 search item type exists in the app so this function is simplified
*/
function getListItem(): typeof TransactionListItem {
return searchTypeToItemMap.transaction.listItem;
}

function getSections(data: OnyxTypes.SearchResults['data']): SearchTransaction[] {
return searchTypeToItemMap.transaction.getSections(data);
}

function getQueryHash(query: string): number {
return UserUtils.hashText(query, 2 ** 32);
}

export {getQueryHash, getListItem, getSections};
27 changes: 27 additions & 0 deletions src/libs/actions/Search.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Onyx from 'react-native-onyx';
import * as API from '@libs/API';
import {READ_COMMANDS} from '@libs/API/types';
import * as SearchUtils from '@libs/SearchUtils';
import ONYXKEYS from '@src/ONYXKEYS';

let isNetworkOffline = false;
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (value) => {
isNetworkOffline = value?.isOffline ?? false;
},
});

function search(query: string) {
if (isNetworkOffline) {
return;
}

const hash = SearchUtils.getQueryHash(query);
API.read(READ_COMMANDS.SEARCH, {query, hash});
}

export {
// eslint-disable-next-line import/prefer-default-export
search,
};
14 changes: 12 additions & 2 deletions src/pages/Search/EmptySearchView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import React from 'react';
import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton';
import * as Illustrations from '@components/Icon/Illustrations';
import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection';
import useLocalize from '@hooks/useLocalize';

function EmptySearchView() {
return <TableListItemSkeleton shouldAnimate />;
const {translate} = useLocalize();

return (
<WorkspaceEmptyStateSection
icon={Illustrations.EmptyStateExpenses}
title={translate('search.searchResults.emptyResults.title')}
subtitle={translate('search.searchResults.emptyResults.subtitle')}
/>
);
}

EmptySearchView.displayName = 'EmptySearchView';
Expand Down
18 changes: 8 additions & 10 deletions src/pages/Search/SearchFilters.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from 'react';
import {View} from 'react-native';
import MenuItem from '@components/MenuItem';
import useActiveRoute from '@hooks/useActiveRoute';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -14,17 +13,20 @@ import ROUTES from '@src/ROUTES';
import type IconAsset from '@src/types/utils/IconAsset';
import SearchFiltersNarrow from './SearchFiltersNarrow';

type SearchFiltersProps = {
query: string;
};

type SearchMenuFilterItem = {
title: string;
icon: IconAsset;
route: Route;
};

function SearchFilters() {
function SearchFilters({query}: SearchFiltersProps) {
const styles = useThemeStyles();
const {singleExecution} = useSingleExecution();
const activeRoute = useActiveRoute();
const {isSmallScreenWidth} = useWindowDimensions();
const {singleExecution} = useSingleExecution();
const {translate} = useLocalize();

const filterItems: SearchMenuFilterItem[] = [
Expand All @@ -35,23 +37,19 @@ function SearchFilters() {
},
];

const currentQuery = activeRoute?.params && 'query' in activeRoute.params ? activeRoute?.params?.query : '';

if (isSmallScreenWidth) {
const activeItemLabel = String(currentQuery);

return (
<SearchFiltersNarrow
filterItems={filterItems}
activeItemLabel={activeItemLabel}
activeItemLabel={String(query)}
/>
);
}

return (
<View style={[styles.pb4, styles.mh3, styles.mt3]}>
{filterItems.map((item) => {
const isActive = item.title.toLowerCase() === currentQuery;
const isActive = item.title.toLowerCase() === query;
const onPress = singleExecution(() => Navigation.navigate(item.route));

return (
Expand Down
11 changes: 5 additions & 6 deletions src/pages/Search/SearchFiltersNarrow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useRef, useState} from 'react';
import {Animated, StyleSheet, View} from 'react-native';
import {Animated, View} from 'react-native';
import Icon from '@components/Icon';
import PopoverMenu from '@components/PopoverMenu';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
Expand Down Expand Up @@ -42,17 +42,16 @@ function SearchFiltersNarrow({filterItems, activeItemLabel}: SearchFiltersNarrow
}));

return (
<>
<View style={[styles.pb4]}>
<PressableWithFeedback
accessible
accessibilityLabel={popoverMenuItems[activeItemIndex]?.text ?? ''}
style={[styles.tabSelectorButton]}
wrapperStyle={[styles.flex1]}
ref={buttonRef}
style={[styles.tabSelectorButton]}
onPress={openMenu}
>
{({hovered}) => (
<Animated.View style={[styles.tabSelectorButton, StyleSheet.absoluteFill, styles.tabBackground(hovered, true, theme.border), styles.mh3]}>
<Animated.View style={[styles.tabSelectorButton, styles.tabBackground(hovered, true, theme.border), styles.w100, styles.mh3]}>
<View style={[styles.flexRow]}>
<Icon
src={popoverMenuItems[activeItemIndex]?.icon ?? Expensicons.All}
Expand All @@ -75,7 +74,7 @@ function SearchFiltersNarrow({filterItems, activeItemLabel}: SearchFiltersNarrow
onItemSelected={closeMenu}
anchorRef={buttonRef}
/>
</>
</View>
);
}

Expand Down
6 changes: 2 additions & 4 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import ScreenWrapper from '@components/ScreenWrapper';
import Search from '@components/Search';
import type {CentralPaneNavigatorParamList} from '@libs/Navigation/types';
import type SCREENS from '@src/SCREENS';
// import EmptySearchView from './EmptySearchView';
import SearchResults from './SearchResults';
import useCustomBackHandler from './useCustomBackHandler';

type SearchPageProps = StackScreenProps<CentralPaneNavigatorParamList, typeof SCREENS.SEARCH.CENTRAL_PANE>;
Expand All @@ -14,8 +13,7 @@ function SearchPage({route}: SearchPageProps) {

return (
<ScreenWrapper testID={SearchPage.displayName}>
<SearchResults query={route.params.query} />
{/* <EmptySearchView /> */}
<Search query={route.params.query} />
</ScreenWrapper>
);
}
Expand Down
Loading

0 comments on commit c50de77

Please sign in to comment.