Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
9fe9f0b
Add search/filter text input to lists with items
daledah Apr 10, 2025
ad2f688
Merge branch 'main' into fix/59864
daledah Apr 18, 2025
80ec5da
fix: apply search bar to other pages
daledah Apr 18, 2025
96eea58
fix: lint
daledah Apr 18, 2025
099938c
Merge branch 'main' into fix/59864
daledah Apr 21, 2025
9d9a33a
fix: handle selection for search results
daledah Apr 21, 2025
02fb491
Merge branch 'main' into fix/59864
daledah Apr 22, 2025
3e097a1
fix: change button icon behavior
daledah Apr 22, 2025
0999cf8
fix: lint
daledah Apr 22, 2025
f53defa
Merge branch 'main' into fix/59864
daledah Apr 23, 2025
2788e70
feat: add search bar to distance rate and tax page
daledah Apr 23, 2025
9c2d0f1
Merge branch 'main' into fix/59864
daledah Apr 23, 2025
f0db512
fix: correct search condition in company card page
daledah Apr 23, 2025
5e36639
Merge branch 'main' into fix/59864
daledah Apr 24, 2025
211e323
Merge branch 'main' into fix/59864
daledah Apr 25, 2025
f8c7941
fix: change component styles and placement
daledah Apr 25, 2025
76203ab
fix: lint
daledah Apr 25, 2025
dfe5e1f
Merge branch 'main' into fix/59864
daledah Apr 28, 2025
d69d154
fix: add search bar to per diem page
daledah Apr 28, 2025
7da3d7d
Merge branch 'main' into fix/59864
daledah Apr 30, 2025
c2c3383
feat: add useTransition usage to pages
daledah Apr 30, 2025
a310f85
Merge branch 'main' into fix/59864
daledah May 2, 2025
3629041
fix: migrate useTransition to a new hook
daledah May 2, 2025
e6a8d9c
Merge branch 'main' into fix/59864
daledah May 5, 2025
a39f5c9
refactor: create const, fix delete error
daledah May 5, 2025
d202a45
Merge branch 'main' into fix/59864
daledah May 5, 2025
be10c79
Merge branch 'main' into fix/59864
daledah May 6, 2025
e9f968e
Merge branch 'main' into fix/59864
daledah May 7, 2025
1dc7cc2
fix: test
daledah May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1995,6 +1995,7 @@ const CONST = {
HEIGHT: 416,
},
DESKTOP_HEADER_PADDING: 12,
SEARCH_ITEM_LIMIT: 15,
CATEGORY_SHORTCUT_BAR_HEIGHT: 32,
SMALL_EMOJI_PICKER_SIZE: {
WIDTH: '100%',
Expand Down
57 changes: 57 additions & 0 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import type IconAsset from '@src/types/utils/IconAsset';
import {MagnifyingGlass} from './Icon/Expensicons';
import Text from './Text';
import TextInput from './TextInput';

type SearchBarProps = {
label: string;
icon?: IconAsset;
inputValue: string;
onChangeText?: (text: string) => void;
onSubmitEditing?: (text: string) => void;
style?: StyleProp<ViewStyle>;
shouldShowEmptyState?: boolean;
};

function SearchBar({label, style, icon = MagnifyingGlass, inputValue, onChangeText, onSubmitEditing, shouldShowEmptyState}: SearchBarProps) {
const styles = useThemeStyles();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const {translate} = useLocalize();

return (
<>
<View style={[styles.getSearchBarStyle(shouldUseNarrowLayout), style]}>
<TextInput
label={label}
accessibilityLabel={label}
role={CONST.ROLE.PRESENTATION}
value={inputValue}
onChangeText={onChangeText}
inputMode={CONST.INPUT_MODE.TEXT}
selectTextOnFocus
spellCheck={false}
icon={inputValue?.length ? undefined : icon}
iconContainerStyle={styles.p0}
onSubmitEditing={() => onSubmitEditing?.(inputValue)}
shouldShowClearButton
shouldHideClearButton={!inputValue?.length}
/>
</View>
{!!shouldShowEmptyState && inputValue.length !== 0 && (
<View style={[styles.ph5, styles.pt3, styles.pb5]}>
<Text style={[styles.textNormal, styles.colorMuted]}>{translate('common.noResultsFoundMatching', {searchString: inputValue})}</Text>
</View>
)}
</>
);
}

SearchBar.displayName = 'SearchBar';
export default SearchBar;
34 changes: 34 additions & 0 deletions src/hooks/useSearchResults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {useEffect, useState, useTransition} from 'react';
import CONST from '@src/CONST';
import usePrevious from './usePrevious';

/**
* This hook filters (and optionally sorts) a dataset based on a search parameter.
* It utilizes `useTransition` to allow the searchQuery to change rapidly, while more expensive renders that occur using
* the result of the filtering and sorting are deprioritized, allowing them to happen in the background.
*/
function useSearchResults<TValue>(data: TValue[], filterData: (datum: TValue, searchInput: string) => boolean, sortData: (data: TValue[]) => TValue[] = (d) => d) {
const [inputValue, setInputValue] = useState('');
const [result, setResult] = useState(data);
const [, startTransition] = useTransition();
const prevData = usePrevious(data);
useEffect(() => {
startTransition(() => {
const normalizedSearchQuery = inputValue.trim().toLowerCase();
const filtered = normalizedSearchQuery.length ? data.filter((item) => filterData(item, normalizedSearchQuery)) : data;
const sorted = sortData(filtered);
setResult(sorted);
Comment on lines +18 to +20
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The company cards array was sorted in place, so React didn't re-render because the reference didn't change. See issue #62202 for details. Fixed in PR #65806.

});
}, [data, filterData, inputValue, sortData]);

useEffect(() => {
if (prevData.length <= CONST.SEARCH_ITEM_LIMIT || data.length > CONST.SEARCH_ITEM_LIMIT) {
return;
}
setInputValue('');
}, [data, prevData]);

return [inputValue, setInputValue, result] as const;
}

export default useSearchResults;
10 changes: 10 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ const translations = {
send: 'Send',
na: 'N/A',
noResultsFound: 'No results found',
noResultsFoundMatching: ({searchString}: {searchString: string}) => `No results found matching "${searchString}"`,
recentDestinations: 'Recent destinations',
timePrefix: "It's",
conjunctionFor: 'for',
Expand Down Expand Up @@ -3011,6 +3012,7 @@ const translations = {
other: 'Delete rates',
}),
deletePerDiemRate: 'Delete per diem rate',
findPerDiemRate: 'Find per diem rate',
areYouSureDelete: () => ({
one: 'Are you sure you want to delete this rate?',
other: 'Are you sure you want to delete these rates?',
Expand Down Expand Up @@ -3803,6 +3805,7 @@ const translations = {
},
},
assignCard: 'Assign card',
findCard: 'Find card',
cardNumber: 'Card number',
commercialFeed: 'Commercial feed',
feedName: ({feedName}: CompanyCardFeedNameParams) => `${feedName} cards`,
Expand Down Expand Up @@ -3837,6 +3840,7 @@ const translations = {
disclaimer:
'The Expensify Visa® Commercial Card is issued by The Bancorp Bank, N.A., Member FDIC, pursuant to a license from Visa U.S.A. Inc. and may not be used at all merchants that accept Visa cards. Apple® and the Apple logo® are trademarks of Apple Inc., registered in the U.S. and other countries. App Store is a service mark of Apple Inc. Google Play and the Google Play logo are trademarks of Google LLC.',
issueCard: 'Issue card',
findCard: 'Find card',
newCard: 'New card',
name: 'Name',
lastFour: 'Last 4',
Expand Down Expand Up @@ -3927,6 +3931,7 @@ const translations = {
addCategory: 'Add category',
editCategory: 'Edit category',
editCategories: 'Edit categories',
findCategory: 'Find category',
categoryRequiredError: 'Category name is required',
existingCategoryError: 'A category with this name already exists',
invalidCategoryName: 'Invalid category name',
Expand Down Expand Up @@ -4101,6 +4106,7 @@ const translations = {
addField: 'Add field',
delete: 'Delete field',
deleteFields: 'Delete fields',
findReportField: 'Find report field',
deleteConfirmation: 'Are you sure you want to delete this report field?',
deleteFieldsConfirmation: 'Are you sure you want to delete these report fields?',
emptyReportFields: {
Expand Down Expand Up @@ -4157,6 +4163,7 @@ const translations = {
addTag: 'Add tag',
editTag: 'Edit tag',
editTags: 'Edit tags',
findTag: 'Find tag',
subtitle: 'Tags add more detailed ways to classify costs.',
emptyTags: {
title: "You haven't created any tags",
Expand Down Expand Up @@ -4189,6 +4196,7 @@ const translations = {
value: 'Value',
taxReclaimableOn: 'Tax reclaimable on',
taxRate: 'Tax rate',
findTaxRate: 'Find tax rate',
error: {
taxRateAlreadyExists: 'This tax name is already in use',
taxCodeAlreadyExists: 'This tax code is already in use',
Expand Down Expand Up @@ -4255,6 +4263,7 @@ const translations = {
one: 'Remove member',
other: 'Remove members',
}),
findMember: 'Find member',
removeWorkspaceMemberButtonTitle: 'Remove from workspace',
removeGroupMemberButtonTitle: 'Remove from group',
removeRoomMemberButtonTitle: 'Remove from chat',
Expand Down Expand Up @@ -4609,6 +4618,7 @@ const translations = {
centrallyManage: 'Centrally manage rates, track in miles or kilometers, and set a default category.',
rate: 'Rate',
addRate: 'Add rate',
findRate: 'Find rate',
trackTax: 'Track tax',
deleteRates: () => ({
one: 'Delete rate',
Expand Down
10 changes: 10 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,7 @@ const translations = {
send: 'Enviar',
na: 'N/A',
noResultsFound: 'No se han encontrado resultados',
noResultsFoundMatching: ({searchString}: {searchString: string}) => `No se encontraron resultados que coincidan con "${searchString}"`,
recentDestinations: 'Destinos recientes',
timePrefix: 'Son las',
conjunctionFor: 'para',
Expand Down Expand Up @@ -3037,6 +3038,7 @@ const translations = {
other: 'Eliminar tasas',
}),
deletePerDiemRate: 'Eliminar tasa per diem',
findPerDiemRate: 'Encontrar tasa per diem',
areYouSureDelete: () => ({
one: '¿Estás seguro de que quieres eliminar esta tasa?',
other: '¿Estás seguro de que quieres eliminar estas tasas?',
Expand Down Expand Up @@ -3845,6 +3847,7 @@ const translations = {
},
},
assignCard: 'Asignar tarjeta',
findCard: 'Encontrar tarjeta',
cardNumber: 'Número de la tarjeta',
commercialFeed: 'Fuente comercial',
feedName: ({feedName}: CompanyCardFeedNameParams) => `Tarjetas ${feedName}`,
Expand Down Expand Up @@ -3879,6 +3882,7 @@ const translations = {
disclaimer:
'La tarjeta comercial Expensify Visa® es emitida por The Bancorp Bank, N.A., miembro de la FDIC, en virtud de una licencia de Visa U.S.A. Inc. y no puede utilizarse en todos los comercios que aceptan tarjetas Visa. Apple® y el logotipo de Apple® son marcas comerciales de Apple Inc. registradas en EE.UU. y otros países. App Store es una marca de servicio de Apple Inc. Google Play y el logotipo de Google Play son marcas comerciales de Google LLC.',
issueCard: 'Emitir tarjeta',
findCard: 'Encontrar tarjeta',
newCard: 'Nueva tarjeta',
name: 'Nombre',
lastFour: '4 últimos',
Expand Down Expand Up @@ -3972,6 +3976,7 @@ const translations = {
addCategory: 'Añadir categoría',
editCategory: 'Editar categoría',
editCategories: 'Editar categorías',
findCategory: 'Encontrar categoría',
categoryRequiredError: 'Lo nombre de la categoría es obligatorio',
existingCategoryError: 'Ya existe una categoría con este nombre',
invalidCategoryName: 'Lo nombre de la categoría es invalido',
Expand Down Expand Up @@ -4149,6 +4154,7 @@ const translations = {
addField: 'Añadir campo',
delete: 'Eliminar campo',
deleteFields: 'Eliminar campos',
findReportField: 'Encontrar campo del informe',
deleteConfirmation: '¿Está seguro de que desea eliminar este campo del informe?',
deleteFieldsConfirmation: '¿Está seguro de que desea eliminar estos campos del informe?',
emptyReportFields: {
Expand Down Expand Up @@ -4205,6 +4211,7 @@ const translations = {
addTag: 'Añadir etiqueta',
editTag: 'Editar etiqueta',
editTags: 'Editar etiquetas',
findTag: 'Encontrar etiquetas',
subtitle: 'Las etiquetas añaden formas más detalladas de clasificar los costos.',
emptyTags: {
title: 'No has creado ninguna etiqueta',
Expand Down Expand Up @@ -4236,6 +4243,7 @@ const translations = {
customTaxName: 'Nombre del impuesto',
value: 'Valor',
taxRate: 'Tasa de impuesto',
findTaxRate: 'Encontrar tasa de impuesto',
taxReclaimableOn: 'Impuesto recuperable en',
error: {
taxRateAlreadyExists: 'Ya existe un impuesto con este nombre',
Expand Down Expand Up @@ -4303,6 +4311,7 @@ const translations = {
one: 'Eliminar miembro',
other: 'Eliminar miembros',
}),
findMember: 'Encontrar miembro',
removeWorkspaceMemberButtonTitle: 'Eliminar del espacio de trabajo',
removeGroupMemberButtonTitle: 'Eliminar del grupo',
removeRoomMemberButtonTitle: 'Eliminar del chat',
Expand Down Expand Up @@ -4658,6 +4667,7 @@ const translations = {
centrallyManage: 'Gestiona centralizadamente las tasas, elige si contabilizar en millas o kilómetros, y define una categoría por defecto',
rate: 'Tasa',
addRate: 'Agregar tasa',
findRate: 'Encontrar tasa',
trackTax: 'Impuesto de seguimiento',
deleteRates: () => ({
one: 'Eliminar tasa',
Expand Down
30 changes: 20 additions & 10 deletions src/libs/CardUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,27 @@ function getEligibleBankAccountsForCard(bankAccountsList: OnyxEntry<BankAccountL
return Object.values(bankAccountsList).filter((bankAccount) => bankAccount?.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && bankAccount?.accountData?.allowDebit);
}

function sortCardsByCardholderName(cardsList: OnyxEntry<WorkspaceCardsList>, personalDetails: OnyxEntry<PersonalDetailsList>, policyMembersAccountIDs: number[]): Card[] {
function getCardsByCardholderName(cardsList: OnyxEntry<WorkspaceCardsList>, policyMembersAccountIDs: number[]): Card[] {
const {cardList, ...cards} = cardsList ?? {};
return Object.values(cards)
.filter((card: Card) => card.accountID && policyMembersAccountIDs.includes(card.accountID))
.sort((cardA: Card, cardB: Card) => {
const userA = cardA.accountID ? personalDetails?.[cardA.accountID] ?? {} : {};
const userB = cardB.accountID ? personalDetails?.[cardB.accountID] ?? {} : {};
return Object.values(cards).filter((card: Card) => card.accountID && policyMembersAccountIDs.includes(card.accountID));
}

const aName = getDisplayNameOrDefault(userA);
const bName = getDisplayNameOrDefault(userB);
function sortCardsByCardholderName(cards: Card[], personalDetails: OnyxEntry<PersonalDetailsList>): Card[] {
return cards.sort((cardA: Card, cardB: Card) => {
const userA = cardA.accountID ? personalDetails?.[cardA.accountID] ?? {} : {};
const userB = cardB.accountID ? personalDetails?.[cardB.accountID] ?? {} : {};
const aName = getDisplayNameOrDefault(userA);
const bName = getDisplayNameOrDefault(userB);
return localeCompare(aName, bName);
});
}

return localeCompare(aName, bName);
});
function filterCardsByPersonalDetails(card: Card, searchQuery: string, personalDetails?: PersonalDetailsList) {
const cardTitle = card.nameValuePairs?.cardTitle?.toLowerCase() ?? '';
const lastFourPAN = card?.lastFourPAN?.toLowerCase() ?? '';
const accountLogin = personalDetails?.[card.accountID ?? CONST.DEFAULT_NUMBER_ID]?.login?.toLowerCase() ?? '';
const accountName = personalDetails?.[card.accountID ?? CONST.DEFAULT_NUMBER_ID]?.displayName?.toLowerCase() ?? '';
return cardTitle.includes(searchQuery) || lastFourPAN.includes(searchQuery) || accountLogin.includes(searchQuery) || accountName.includes(searchQuery);
}

function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK, illustrations: IllustrationsType): IconAsset {
Expand Down Expand Up @@ -636,5 +644,7 @@ export {
isExpensifyCardFullySetUp,
filterInactiveCards,
getFundIdFromSettingsKey,
getCardsByCardholderName,
filterCardsByPersonalDetails,
getCompanyCardDescription,
};
Loading