From 9fe9f0b529e56a9e9eb194bc81c0126242e23d85 Mon Sep 17 00:00:00 2001 From: daledah Date: Fri, 11 Apr 2025 00:36:21 +0700 Subject: [PATCH 01/15] Add search/filter text input to lists with items --- .../categories/WorkspaceCategoriesPage.tsx | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 3c807548c7498..12e108e95ba91 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -52,6 +52,7 @@ import type SCREENS from '@src/SCREENS'; import type {PolicyCategory} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import TextInput from '@components/TextInput'; type PolicyOption = ListItem & { /** Category name is used as a key for the selectedCategories state */ @@ -84,6 +85,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; const currentConnectionName = getCurrentConnectionName(policy); const isQuickSettingsFlow = !!backTo; + const [searchQuery, setSearchQuery] = useState(''); + const [inputValue, setInputValue] = useState(''); const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true; @@ -102,6 +105,23 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const cleanupSelectedOption = useCallback(() => setSelectedCategories({}), []); useCleanupSelectedOptions(cleanupSelectedOption); + const getSearchBar = () => { + return ( + + setSearchQuery(inputValue)} + shouldShowClearButton + /> + + ); + }; + + useEffect(() => { if (isEmptyObject(selectedCategories) || !canSelectMultiple) { return; @@ -330,8 +350,18 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setSelectedCategories({}); }, [setSelectedCategories, selectionMode?.isEnabled]); - const hasVisibleCategories = categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); + const filteredCategoryList = useMemo(() => { + if (!searchQuery.trim()) { + return categoryList; + } + const lowerQuery = searchQuery.trim().toLowerCase(); + return categoryList.filter(cat => + cat.text?.toLowerCase().includes(lowerQuery) + ); + }, [searchQuery, categoryList]); + + const hasVisibleCategories = categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const getHeaderText = () => ( {!hasSyncError && isConnectedToAccounting ? ( @@ -437,6 +467,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { danger /> {shouldUseNarrowLayout && {getHeaderButtons()}} + {categoryList.length > 15 && getSearchBar()} {(!shouldUseNarrowLayout || !hasVisibleCategories || isLoading) && getHeaderText()} {isLoading && ( )} - {!hasVisibleCategories && !isLoading && ( + {((!hasVisibleCategories && !isLoading) || filteredCategoryList.length === 0) && ( item && toggleCategory(item)} - sections={[{data: categoryList, isDisabled: false}]} + sections={[{data: filteredCategoryList, isDisabled: false}]} onCheckboxPress={toggleCategory} onSelectRow={navigateToCategorySettings} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} From 80ec5dadcefe8433572b8a8f6060a25b81d5f2f7 Mon Sep 17 00:00:00 2001 From: daledah Date: Fri, 18 Apr 2025 17:40:50 +0700 Subject: [PATCH 02/15] fix: apply search bar to other pages --- src/components/SearchBar.tsx | 58 +++++++++++++++ src/languages/en.ts | 5 ++ src/languages/es.ts | 5 ++ src/pages/workspace/WorkspaceMembersPage.tsx | 73 ++++++++++++------- .../categories/WorkspaceCategoriesPage.tsx | 44 ++++------- .../ReportFieldsListValuesPage.tsx | 30 +++++++- .../WorkspaceReportFieldsPage.tsx | 31 +++++++- .../workspace/tags/WorkspaceTagsPage.tsx | 31 ++++++-- .../workspace/tags/WorkspaceViewTagsPage.tsx | 28 +++++-- src/styles/index.ts | 6 ++ 10 files changed, 240 insertions(+), 71 deletions(-) create mode 100644 src/components/SearchBar.tsx diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000000000..16ad089b37dab --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,58 @@ +import React, {useState} 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; + shouldShowEmptyState?: boolean; +}; + +function SearchBar({label, style, icon = MagnifyingGlass, inputValue, onChangeText, onSubmitEditing, shouldShowEmptyState}: SearchBarProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {translate} = useLocalize(); + const [isTextInputFocused, setIsTextInputFocused] = useState(false); + + return ( + <> + + onSubmitEditing?.(inputValue)} + shouldShowClearButton + onFocus={() => setIsTextInputFocused(true)} + onBlur={() => setIsTextInputFocused(false)} + /> + + {!!shouldShowEmptyState && inputValue.length !== 0 && ( + + {translate('common.noResultsFoundMatching', {searchString: inputValue})} + + )} + + ); +} + +SearchBar.displayName = 'SearchBar'; +export default SearchBar; diff --git a/src/languages/en.ts b/src/languages/en.ts index ed17381a63783..f87046cdaced4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -365,6 +365,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', @@ -3902,6 +3903,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', @@ -4076,6 +4078,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: { @@ -4132,6 +4135,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", @@ -4230,6 +4234,7 @@ const translations = { one: 'Remove member', other: 'Remove members', }), + findMember: 'Find member', removeWorkspaceMemberButtonTitle: 'Remove from workspace', removeGroupMemberButtonTitle: 'Remove from group', removeRoomMemberButtonTitle: 'Remove from chat', diff --git a/src/languages/es.ts b/src/languages/es.ts index c08ebd3fdf065..3f14ead4136b7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -356,6 +356,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', @@ -3946,6 +3947,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', @@ -4123,6 +4125,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: { @@ -4179,6 +4182,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', @@ -4277,6 +4281,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', diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index af1d53fbdb15a..d5f66f7f37181 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -15,6 +15,7 @@ import DecisionModal from '@components/DecisionModal'; import {Download, FallbackAvatar, MakeAdmin, Plus, RemoveMembers, Table, User, UserEye} from '@components/Icon/Expensicons'; import {ReceiptWrangler} from '@components/Icon/Illustrations'; import MessagesRow from '@components/MessagesRow'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem, SelectionListHandle} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; @@ -99,6 +100,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const prevPersonalDetails = usePrevious(personalDetails); const {translate, formatPhoneNumber, preferredLocale} = useLocalize(); + const [inputValue, setInputValue] = useState(''); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -464,6 +466,14 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const data = useMemo(() => getUsers(), [getUsers]); + const filteredData = useMemo(() => { + if (!inputValue.trim()) { + return data; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return data.filter((item) => !!item.text?.toLowerCase().includes(lowerQuery) || !!item.alternateText?.toLowerCase().includes(lowerQuery)); + }, [data, inputValue]); + useEffect(() => { if (!isFocused) { return; @@ -688,6 +698,15 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson {() => ( <> {shouldUseNarrowLayout && {getHeaderButtons()}} + {shouldUseNarrowLayout ? {getHeaderContent()} : getHeaderContent()} + {data.length > 15 && ( + + )} setIsOfflineModalVisible(false)} @@ -724,33 +743,33 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson isVisible={isDownloadFailureModalVisible} onClose={() => setIsDownloadFailureModalVisible(false)} /> - - item && toggleUser(item?.accountID)} - shouldUseUserSkeletonView - disableKeyboardShortcuts={removeMembersConfirmModalVisible} - headerMessage={getHeaderMessage()} - headerContent={!shouldUseNarrowLayout && getHeaderContent()} - onSelectRow={openMemberDetails} - shouldSingleExecuteRowSelect={!isPolicyAdmin} - onCheckboxPress={(item) => toggleUser(item.accountID)} - onSelectAll={() => toggleAllUsers(data)} - onDismissError={dismissError} - showLoadingPlaceholder={isLoading} - shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} - textInputRef={textInputRef} - customListHeader={getCustomListHeader()} - listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} - listHeaderContent={shouldUseNarrowLayout ? {getHeaderContent()} : null} - showScrollIndicator={false} - addBottomSafeAreaPadding - /> - + {!!filteredData.length && ( + + item && toggleUser(item?.accountID)} + shouldUseUserSkeletonView + disableKeyboardShortcuts={removeMembersConfirmModalVisible} + headerMessage={getHeaderMessage()} + onSelectRow={openMemberDetails} + shouldSingleExecuteRowSelect={!isPolicyAdmin} + onCheckboxPress={(item) => toggleUser(item.accountID)} + onSelectAll={() => toggleAllUsers(filteredData)} + onDismissError={dismissError} + showLoadingPlaceholder={isLoading} + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + textInputRef={textInputRef} + customListHeader={getCustomListHeader()} + listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} + showScrollIndicator={false} + addBottomSafeAreaPadding + /> + + )} )} diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index ff3f59b82ec54..228d977c6fc68 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -14,6 +14,7 @@ import * as Illustrations from '@components/Icon/Illustrations'; import LottieAnimations from '@components/LottieAnimations'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; @@ -21,7 +22,6 @@ import CustomListHeader from '@components/SelectionListWithModal/CustomListHeade import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Switch from '@components/Switch'; import Text from '@components/Text'; -import TextInput from '@components/TextInput'; import TextLink from '@components/TextLink'; import useAutoTurnSelectionModeOffWhenHasNoActiveOption from '@hooks/useAutoTurnSelectionModeOffWhenHasNoActiveOption'; import useCleanupSelectedOptions from '@hooks/useCleanupSelectedOptions'; @@ -87,7 +87,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; const currentConnectionName = getCurrentConnectionName(policy); const isQuickSettingsFlow = !!backTo; - const [searchQuery, setSearchQuery] = useState(''); const [inputValue, setInputValue] = useState(''); const {canUseLeftHandBar} = usePermissions(); @@ -108,22 +107,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const cleanupSelectedOption = useCallback(() => setSelectedCategories({}), []); useCleanupSelectedOptions(cleanupSelectedOption); - const getSearchBar = () => { - return ( - - setSearchQuery(inputValue)} - shouldShowClearButton - /> - - ); - }; - useEffect(() => { if (isEmptyObject(selectedCategories) || !canSelectMultiple) { return; @@ -353,12 +336,12 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, [setSelectedCategories, selectionMode?.isEnabled]); const filteredCategoryList = useMemo(() => { - if (!searchQuery.trim()) { + if (!inputValue.trim()) { return categoryList; } - const lowerQuery = searchQuery.trim().toLowerCase(); + const lowerQuery = inputValue.trim().toLowerCase(); return categoryList.filter((cat) => cat.text?.toLowerCase().includes(lowerQuery)); - }, [searchQuery, categoryList]); + }, [inputValue, categoryList]); const hasVisibleCategories = categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const getHeaderText = () => ( @@ -472,8 +455,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { danger /> {shouldUseNarrowLayout && {getHeaderButtons()}} - {categoryList.length > 15 && getSearchBar()} - {(!shouldUseNarrowLayout || !hasVisibleCategories || isLoading) && getHeaderText()} + {hasVisibleCategories && !isLoading && getHeaderText()} + {categoryList.length > 15 && ( + + )} {isLoading && ( )} - - {((!hasVisibleCategories && !isLoading) || filteredCategoryList.length === 0) && ( + {!hasVisibleCategories && !isLoading && inputValue.length === 0 && ( diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index 9c84adbafcc76..82498f4d65235 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -11,6 +11,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; @@ -97,6 +98,8 @@ function ReportFieldsListValuesPage({ return [reportFieldValues, reportFieldDisabledValues]; }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID]); + const [inputValue, setInputValue] = useState(''); + const updateReportFieldListValueEnabled = useCallback( (value: boolean, valueIndex: number) => { if (reportFieldID) { @@ -137,6 +140,21 @@ function ReportFieldsListValuesPage({ return [{data, isDisabled: false}]; }, [canSelectMultiple, disabledListValues, listValues, selectedValues, translate, updateReportFieldListValueEnabled]); + const filteredListValuesSections = useMemo(() => { + if (!inputValue.trim()) { + return listValuesSections; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return [ + { + data: listValuesSections.at(0)?.data.filter((reportField) => reportField.text?.toLowerCase().includes(lowerQuery)) ?? [], + isDisabled: listValuesSections?.at(0)?.isDisabled, + }, + ]; + }, [listValuesSections, inputValue]); + + const filteredListValuesArray = (filteredListValuesSections.at(0)?.data ?? []).map((item) => item.value); + const shouldShowEmptyState = Object.values(listValues ?? {}).length <= 0; const selectedValuesArray = Object.keys(selectedValues).filter((key) => selectedValues[key]); @@ -148,7 +166,7 @@ function ReportFieldsListValuesPage({ }; const toggleAllValues = () => { - setSelectedValues(selectedValuesArray.length > 0 ? {} : Object.fromEntries(listValues.map((value) => [value, true]))); + setSelectedValues(selectedValuesArray.length > 0 ? {} : Object.fromEntries(filteredListValuesArray.map((value) => [value, true]))); }; const handleDeleteValues = () => { @@ -325,6 +343,14 @@ function ReportFieldsListValuesPage({ {translate('workspace.reportFields.listInputSubtitle')} + {(listValuesSections.at(0)?.data?.length ?? 0) > 1 && ( + + )} {shouldShowEmptyState && ( item && toggleValue(item)} - sections={listValuesSections} + sections={filteredListValuesSections} onCheckboxPress={toggleValue} onSelectRow={openListValuePage} onSelectAll={toggleAllValues} diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index 4ae84c7b996b5..9dcf97d1d3aa3 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -14,6 +14,7 @@ import {Pencil} from '@components/Icon/Illustrations'; import LottieAnimations from '@components/LottieAnimations'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import SearchBar from '@components/SearchBar'; import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; @@ -90,6 +91,8 @@ function WorkspaceReportFieldsPage({ const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; const currentConnectionName = getCurrentConnectionName(policy); + const [inputValue, setInputValue] = useState(''); + const canSelectMultiple = !hasReportAccountingConnections && (isSmallScreenWidth ? selectionMode?.isEnabled : true); const fetchReportFields = useCallback(() => { @@ -134,7 +137,20 @@ function WorkspaceReportFieldsPage({ ]; }, [filteredPolicyFieldList, policy, selectedReportFields, canSelectMultiple, translate]); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(reportFieldsSections.at(0)?.data ?? ([] as ListItem[])); + const filteredReportFieldsSections = useMemo(() => { + if (!inputValue.trim()) { + return reportFieldsSections; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return [ + { + data: reportFieldsSections.at(0)?.data.filter((reportField) => reportField.text.toLowerCase().includes(lowerQuery)) ?? [], + isDisabled: reportFieldsSections?.at(0)?.isDisabled, + }, + ]; + }, [reportFieldsSections, inputValue]); + + useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredReportFieldsSections.at(0)?.data ?? ([] as ListItem[])); const updateSelectedReportFields = (item: ReportFieldForList) => { const fieldKey = getReportFieldKey(item.fieldID); @@ -273,6 +289,15 @@ function WorkspaceReportFieldsPage({ danger /> {(!shouldUseNarrowLayout || !hasVisibleReportField || isLoading) && getHeaderText()} + {!shouldShowEmptyState && !isLoading && shouldUseNarrowLayout && getHeaderText()} + {(reportFieldsSections.at(0)?.data?.length ?? 0) > 15 && ( + + )} {isLoading && ( )} - {shouldShowEmptyState && ( + {shouldShowEmptyState && filteredReportFieldsSections.at(0)?.data.length === 0 && ( item && updateSelectedReportFields(item)} - sections={reportFieldsSections} + sections={filteredReportFieldsSections} onCheckboxPress={updateSelectedReportFields} onSelectRow={navigateToReportFieldsSettings} onSelectAll={toggleAllReportFields} diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 6e3a2d258eb25..d3fcbaae87471 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -15,6 +15,7 @@ import LottieAnimations from '@components/LottieAnimations'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; @@ -95,6 +96,8 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const {isOffline} = useNetwork({onReconnect: fetchTags}); + const [inputValue, setInputValue] = useState(''); + useEffect(() => { fetchTags(); // eslint-disable-next-line react-compiler/react-compiler @@ -206,13 +209,21 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { })); }, [isMultiLevelTags, policyTagLists, selectedTags, canSelectMultiple, translate, updateWorkspaceRequiresTag, updateWorkspaceTagEnabled]); - const tagListKeyedByName = useMemo( + const filteredTagList = useMemo(() => { + if (!inputValue.trim()) { + return tagList; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return tagList.filter((item) => !!item.text?.toLowerCase().includes(lowerQuery) || !!item.value?.toLowerCase().includes(lowerQuery)); + }, [tagList, inputValue]); + + const filteredTagListKeyedByName = useMemo( () => - tagList.reduce>((acc, tag) => { + filteredTagList.reduce>((acc, tag) => { acc[tag.value] = tag; return acc; }, {}), - [tagList], + [filteredTagList], ); const toggleTag = (tag: TagListItem) => { @@ -223,7 +234,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }; const toggleAllTags = () => { - const availableTags = tagList.filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const availableTags = filteredTagList.filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); setSelectedTags(Object.keys(selectedTags).length > 0 ? {} : Object.fromEntries(availableTags.map((item) => [item.value, true]))); }; @@ -310,7 +321,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { let disabledTagCount = 0; const tagsToEnable: Record = {}; for (const tagName of selectedTagsArray) { - if (tagListKeyedByName[tagName]?.enabled) { + if (filteredTagListKeyedByName[tagName]?.enabled) { enabledTagCount++; tagsToDisable[tagName] = { name: tagName, @@ -478,6 +489,14 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { danger /> {(!shouldUseNarrowLayout || !hasVisibleTags || isLoading) && getHeaderText()} + {tagList.length > 15 && ( + + )} {isLoading && ( item && toggleTag(item)} - sections={[{data: tagList, isDisabled: false}]} + sections={[{data: filteredTagList, isDisabled: false}]} onCheckboxPress={toggleTag} onSelectRow={navigateToTagSettings} shouldSingleExecuteRowSelect={!canSelectMultiple} diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index 831260db3ace2..0057d32bc4029 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -10,6 +10,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import SelectionListWithModal from '@components/SelectionListWithModal'; import CustomListHeader from '@components/SelectionListWithModal/CustomListHeader'; @@ -69,6 +70,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { const currentTagListName = useMemo(() => getTagListName(policyTags, route.params.orderWeight), [policyTags, route.params.orderWeight]); const currentPolicyTag = policyTags?.[currentTagListName]; const isQuickSettingsFlow = !!backTo; + const [inputValue, setInputValue] = useState(''); const fetchTags = useCallback(() => { openPolicyTagsPage(policyID); @@ -128,15 +130,23 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { [currentPolicyTag?.tags, selectedTags, canSelectMultiple, translate, updateWorkspaceTagEnabled], ); + const filteredTagList = useMemo(() => { + if (!inputValue.trim()) { + return tagList; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return tagList.filter((item) => !!item.text?.toLowerCase().includes(lowerQuery) || !!item.value?.toLowerCase().includes(lowerQuery)); + }, [tagList, inputValue]); + const hasDependentTags = useMemo(() => hasDependentTagsPolicyUtils(policy, policyTags), [policy, policyTags]); const tagListKeyedByName = useMemo( () => - tagList.reduce>((acc, tag) => { + filteredTagList.reduce>((acc, tag) => { acc[tag.value] = tag; return acc; }, {}), - [tagList], + [filteredTagList], ); if (!currentPolicyTag) { @@ -151,7 +161,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { }; const toggleAllTags = () => { - const availableTags = tagList.filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const availableTags = filteredTagList.filter((tag) => tag.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const anySelected = availableTags.some((tag) => !!selectedTags[tag.value]); setSelectedTags(anySelected ? {} : Object.fromEntries(availableTags.map((t) => [t.value, true]))); @@ -346,12 +356,20 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { color={theme.spinner} /> )} - {tagList.length > 0 && !isLoading && ( + {tagList.length > 15 && ( + + )} + {filteredTagList.length > 0 && !isLoading && ( item && toggleTag(item)} - sections={[{data: tagList, isDisabled: false}]} + sections={[{data: filteredTagList, isDisabled: false}]} onCheckboxPress={toggleTag} onSelectRow={navigateToTagSettings} onSelectAll={toggleAllTags} diff --git a/src/styles/index.ts b/src/styles/index.ts index a299a8965aa9c..672b7c999420b 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5659,6 +5659,12 @@ const styles = (theme: ThemeColors) => left: 0, right: 0, }, + + getSearchBarStyle: (shouldUseNarrowLayout: boolean) => ({ + maxWidth: shouldUseNarrowLayout ? '100%' : 300, + marginHorizontal: 20, + marginBottom: 20, + }), } satisfies Styles); type ThemeStyles = ReturnType; From 96eea58ee27d2a5ac9d04936b8d3d0d680a1b649 Mon Sep 17 00:00:00 2001 From: daledah Date: Fri, 18 Apr 2025 17:46:02 +0700 Subject: [PATCH 03/15] fix: lint --- src/pages/workspace/WorkspaceMembersPage.tsx | 4 ++-- src/pages/workspace/categories/WorkspaceCategoriesPage.tsx | 4 ++-- .../workspace/reportFields/ReportFieldsListValuesPage.tsx | 2 +- .../workspace/reportFields/WorkspaceReportFieldsPage.tsx | 2 +- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 4 ++-- src/pages/workspace/tags/WorkspaceViewTagsPage.tsx | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index d5f66f7f37181..38428179dd337 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -111,9 +111,9 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson [isOfflineAndNoMemberDataAvailable, personalDetails, policy?.employeeList], ); - const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`); + const [invitedEmailsToAccountIDsDraft] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT}${route.params.policyID.toString()}`, {canBeMissing: true}); const {selectionMode} = useMobileSelectionMode(); - const [session] = useOnyx(ONYXKEYS.SESSION); + const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false}); const currentUserAccountID = Number(session?.accountID); const selectionListRef = useRef(null); const isFocused = useIsFocused(); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 228d977c6fc68..86d62f0797e2d 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -80,8 +80,8 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const backTo = route.params?.backTo; const policy = usePolicy(policyId); const {selectionMode} = useMobileSelectionMode(); - const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyId}`); - const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyId}`, {canBeMissing: false}); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`, {canBeMissing: true}); const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const hasSyncError = shouldShowSyncError(policy, isSyncInProgress); const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index 82498f4d65235..0ae0b3a80f257 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -72,7 +72,7 @@ function ReportFieldsListValuesPage({ // See https://github.com/Expensify/App/issues/48724 for more details // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); - const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT); + const [formDraft] = useOnyx(ONYXKEYS.FORMS.WORKSPACE_REPORT_FIELDS_FORM_DRAFT, {canBeMissing: true}); const {selectionMode} = useMobileSelectionMode(); const [selectedValues, setSelectedValues] = useState>({}); diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index 9dcf97d1d3aa3..5dad765c0a181 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -85,7 +85,7 @@ function WorkspaceReportFieldsPage({ const [selectedReportFields, setSelectedReportFields] = useState([]); const [deleteReportFieldsConfirmModalVisible, setDeleteReportFieldsConfirmModalVisible] = useState(false); const hasReportAccountingConnections = hasAccountingConnections(policy); - const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`, {canBeMissing: true}); const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const hasSyncError = shouldShowSyncError(policy, isSyncInProgress); const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index d3fcbaae87471..94a69278bf10a 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -79,10 +79,10 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const policyID = route.params.policyID; const backTo = route.params.backTo; const policy = usePolicy(policyID); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: false}); const {selectionMode} = useMobileSelectionMode(); const {environmentURL} = useEnvironment(); - const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`, {canBeMissing: true}); const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const hasSyncError = shouldShowSyncError(policy, isSyncInProgress); const isConnectedToAccounting = Object.keys(policy?.connections ?? {}).length > 0; diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index 0057d32bc4029..7958d85454d02 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -65,7 +65,7 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { const policyID = route.params.policyID; const backTo = route.params.backTo; const policy = usePolicy(policyID); - const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: false}); const {selectionMode} = useMobileSelectionMode(); const currentTagListName = useMemo(() => getTagListName(policyTags, route.params.orderWeight), [policyTags, route.params.orderWeight]); const currentPolicyTag = policyTags?.[currentTagListName]; From 9d9a33ab877b0cb466ac9fc11d1c05da8b0606c6 Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 21 Apr 2025 10:22:31 +0700 Subject: [PATCH 04/15] fix: handle selection for search results --- .../categories/WorkspaceCategoriesPage.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 86d62f0797e2d..85befe0342b17 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -169,7 +169,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, []); }, [policyCategories, isOffline, selectedCategories, canSelectMultiple, translate, updateWorkspaceRequiresCategory]); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(categoryList); + const filteredCategoryList = useMemo(() => { + if (!inputValue.trim()) { + return categoryList; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return categoryList.filter((cat) => cat.text?.toLowerCase().includes(lowerQuery)); + }, [inputValue, categoryList]); + + useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredCategoryList); const toggleCategory = useCallback((category: PolicyOption) => { setSelectedCategories((prev) => { @@ -182,7 +190,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, []); const toggleAllCategories = () => { - const availableCategories = categoryList.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const availableCategories = filteredCategoryList.filter((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); const someSelected = availableCategories.some((category) => selectedCategories[category.keyForList]); setSelectedCategories(someSelected ? {} : Object.fromEntries(availableCategories.map((item) => [item.keyForList, true]))); }; @@ -335,14 +343,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { setSelectedCategories({}); }, [setSelectedCategories, selectionMode?.isEnabled]); - const filteredCategoryList = useMemo(() => { - if (!inputValue.trim()) { - return categoryList; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return categoryList.filter((cat) => cat.text?.toLowerCase().includes(lowerQuery)); - }, [inputValue, categoryList]); - const hasVisibleCategories = categoryList.some((category) => category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || isOffline); const getHeaderText = () => ( From 3e097a1e8126916163304be3e86e11bd557c5d10 Mon Sep 17 00:00:00 2001 From: daledah Date: Tue, 22 Apr 2025 22:51:53 +0700 Subject: [PATCH 05/15] fix: change button icon behavior --- src/components/SearchBar.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 16ad089b37dab..62a80e137278a 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -24,7 +24,6 @@ function SearchBar({label, style, icon = MagnifyingGlass, inputValue, onChangeTe const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); - const [isTextInputFocused, setIsTextInputFocused] = useState(false); return ( <> @@ -38,11 +37,11 @@ function SearchBar({label, style, icon = MagnifyingGlass, inputValue, onChangeTe inputMode={CONST.INPUT_MODE.TEXT} selectTextOnFocus spellCheck={false} - icon={inputValue?.length && isTextInputFocused ? undefined : icon} + icon={inputValue?.length ? undefined : icon} + iconContainerStyle={styles.p0} onSubmitEditing={() => onSubmitEditing?.(inputValue)} shouldShowClearButton - onFocus={() => setIsTextInputFocused(true)} - onBlur={() => setIsTextInputFocused(false)} + shouldHideClearButton={!inputValue?.length} /> {!!shouldShowEmptyState && inputValue.length !== 0 && ( From 0999cf88bcd29a3baf427130ebdf81ceedf3fe2c Mon Sep 17 00:00:00 2001 From: daledah Date: Tue, 22 Apr 2025 22:53:33 +0700 Subject: [PATCH 06/15] fix: lint --- src/components/SearchBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 62a80e137278a..0f9a289e0f0ae 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; From 2788e70de66fe8c2478e2f589c9493eb7a16bc62 Mon Sep 17 00:00:00 2001 From: daledah Date: Wed, 23 Apr 2025 15:43:25 +0700 Subject: [PATCH 07/15] feat: add search bar to distance rate and tax page --- src/languages/en.ts | 4 ++ src/languages/es.ts | 4 ++ .../WorkspaceCompanyCardsList.tsx | 48 ++++++++++++++---- .../distanceRates/PolicyDistanceRatesPage.tsx | 25 ++++++++-- .../WorkspaceExpensifyCardListPage.tsx | 49 +++++++++++++++---- .../workspace/taxes/WorkspaceTaxesPage.tsx | 31 ++++++++++-- 6 files changed, 133 insertions(+), 28 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 4726aa4a4a210..cba956c2499b5 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3790,6 +3790,7 @@ const translations = { }, }, assignCard: 'Assign card', + findCard: 'Find card', cardNumber: 'Card number', commercialFeed: 'Commercial feed', feedName: ({feedName}: CompanyCardFeedNameParams) => `${feedName} cards`, @@ -3824,6 +3825,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 Expensify card', newCard: 'New card', name: 'Name', lastFour: 'Last 4', @@ -4179,6 +4181,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', @@ -4599,6 +4602,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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5fb1aab958d14..6820017ca5b11 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3831,6 +3831,7 @@ const translations = { }, }, assignCard: 'Asignar tarjeta', + findCard: 'Encontrar tarjeta', cardNumber: 'Número de la tarjeta', commercialFeed: 'Fuente comercial', feedName: ({feedName}: CompanyCardFeedNameParams) => `Tarjetas ${feedName}`, @@ -3865,6 +3866,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 Tarjetas Expensify', newCard: 'Nueva tarjeta', name: 'Nombre', lastFour: '4 últimos', @@ -4225,6 +4227,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', @@ -4647,6 +4650,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', diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 66239d55ddb2b..27bebcb93d7a0 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -1,10 +1,11 @@ -import React, {useCallback, useMemo} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; +import SearchBar from '@components/SearchBar'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -34,11 +35,26 @@ type WorkspaceCompanyCardsListProps = { function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDisabledAssignCardButton}: WorkspaceCompanyCardsListProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); const sortedCards = useMemo(() => sortCardsByCardholderName(cardsList, personalDetails), [cardsList, personalDetails]); + const [inputValue, setInputValue] = useState(''); + const filteredSortedCards = useMemo(() => { + if (!inputValue) { + return sortedCards; + } + const lowerValue = inputValue.toLowerCase(); + return sortedCards.filter((card) => { + 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(lowerValue) || lastFourPAN.includes(lowerValue) || accountLogin.includes(lowerValue) || accountName.includes(lowerValue); + }); + }, [inputValue, personalDetails, sortedCards]); + const renderItem = useCallback( ({item, index}: ListRenderItemInfo) => { const cardID = Object.keys(cardsList ?? {}).find((id) => cardsList?.[id].cardID === item.cardID); @@ -104,14 +120,26 @@ function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDis ); } + const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; + return ( - + <> + {sortedCards.length > 0 && ( + + )} + + ); } diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 5adbb3a092ea0..3acf5aa77405d 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -8,6 +8,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; import ScreenWrapper from '@components/ScreenWrapper'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; @@ -63,6 +64,7 @@ function PolicyDistanceRatesPage({ const isFocused = useIsFocused(); const policy = usePolicy(policyID); const {selectionMode} = useMobileSelectionMode(); + const [inputValue, setInputValue] = useState(''); const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true; @@ -177,6 +179,16 @@ function PolicyDistanceRatesPage({ [customUnitRates, translate, customUnit, selectedDistanceRates, canSelectMultiple, policy?.pendingAction, updateDistanceRateEnabled], ); + const filteredDistanceRatesList = useMemo(() => { + if (!inputValue.trim()) { + return distanceRatesList; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return distanceRatesList.filter((rate) => rate.text?.toLowerCase().includes(lowerQuery)); + }, [distanceRatesList, inputValue]); + + const hasVisibleRates = useMemo(() => Object.values(customUnitRates).some((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [customUnitRates]); + const addRate = () => { Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(policyID)); }; @@ -363,7 +375,15 @@ function PolicyDistanceRatesPage({ {!shouldUseNarrowLayout && headerButtons} {shouldUseNarrowLayout && {headerButtons}} - {!shouldUseNarrowLayout && getHeaderText()} + {Object.values(customUnitRates).length > 0 && getHeaderText()} + {Object.values(customUnitRates).length > 15 && ( + + )} {isLoading && ( item && toggleRate(item)} - sections={[{data: distanceRatesList, isDisabled: false}]} + sections={[{data: filteredDistanceRatesList, isDisabled: false}]} onCheckboxPress={toggleRate} onSelectRow={openRateDetails} onSelectAll={toggleAllRates} @@ -386,7 +406,6 @@ function PolicyDistanceRatesPage({ shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} customListHeader={getCustomListHeader()} listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} - listHeaderContent={shouldUseNarrowLayout ? getHeaderText() : null} showScrollIndicator={false} /> )} diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 17f3ad65ae600..21b8c6f0b75f9 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -13,6 +13,7 @@ import {HandCard} from '@components/Icon/Illustrations'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; +import SearchBar from '@components/SearchBar'; import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; import useLocalize from '@hooks/useLocalize'; @@ -54,14 +55,14 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const policyID = route.params.policyID; const policy = usePolicy(policyID); const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const [cardOnWaitlist] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST}${policyID}`); - const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const [cardOnWaitlist] = useOnyx(`${ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST}${policyID}`, {canBeMissing: true}); + const [cardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${fundID}`, {canBeMissing: true}); const allExpensifyCardFeeds = useExpensifyCardFeeds(policyID); const shouldShowSelector = Object.keys(allExpensifyCardFeeds ?? {}).length > 1; - const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => !!account?.delegatedAccess?.delegate}); + const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: (account) => !!account?.delegatedAccess?.delegate, canBeMissing: false}); const [isNoDelegateAccessMenuVisible, setIsNoDelegateAccessMenuVisible] = useState(false); const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout; @@ -72,6 +73,22 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const sortedCards = useMemo(() => sortCardsByCardholderName(cardsList, personalDetails), [cardsList, personalDetails]); + const [inputValue, setInputValue] = useState(''); + + const filteredSortedCards = useMemo(() => { + if (!inputValue) { + return sortedCards; + } + const lowerValue = inputValue.toLowerCase(); + return sortedCards.filter((card) => { + 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(lowerValue) || lastFourPAN.includes(lowerValue) || accountLogin.includes(lowerValue) || accountName.includes(lowerValue); + }); + }, [inputValue, sortedCards, personalDetails]); + const handleIssueCardPress = () => { if (isActingAsDelegate) { setIsNoDelegateAccessMenuVisible(true); @@ -141,6 +158,8 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle(); + const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; + return ( ) : ( - + <> + {sortedCards.length > 15 && ( + + )} + + )} !policy?.taxRates?.taxes[taxID]?.isDisabled).length; const disabledRatesCount = selectedTaxesIDs.length - enabledRatesCount; + const [inputValue, setInputValue] = useState(''); const fetchTaxes = useCallback(() => { openPolicyTaxesPage(policyID); @@ -176,6 +178,18 @@ function WorkspaceTaxesPage({ .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); }, [policy, textForDefault, selectedTaxesIDs, canSelectMultiple, translate, updateWorkspaceTaxEnabled]); + const filteredTaxesList = useMemo(() => { + if (!inputValue) { + return taxesList; + } + const lowerQuery = inputValue.toLowerCase(); + return taxesList.filter((tax) => { + const taxName = tax.text?.toLowerCase() ?? ''; + const taxAlternateText = tax.alternateText?.toLowerCase() ?? ''; + return taxName.includes(lowerQuery) || taxAlternateText.includes(lowerQuery); + }); + }, [inputValue, taxesList]); + const isLoading = !isOffline && taxesList === undefined; const toggleTax = (tax: ListItem) => { @@ -193,7 +207,7 @@ function WorkspaceTaxesPage({ }; const toggleAllTaxes = () => { - const taxesToSelect = taxesList.filter( + const taxesToSelect = filteredTaxesList.filter( (tax) => tax.keyForList !== defaultExternalID && tax.keyForList !== foreignTaxDefault && tax.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, ); setSelectedTaxesIDs((prev) => { @@ -363,7 +377,15 @@ function WorkspaceTaxesPage({ {shouldUseNarrowLayout && {headerButtons}} - {!shouldUseNarrowLayout && getHeaderText()} + {getHeaderText()} + {taxesList.length > 15 && ( + + )} {isLoading && ( item && toggleTax(item)} - sections={[{data: taxesList, isDisabled: false}]} + sections={[{data: filteredTaxesList, isDisabled: false}]} onCheckboxPress={toggleTax} onSelectRow={navigateToEditTaxRate} onSelectAll={toggleAllTaxes} ListItem={TableListItem} customListHeader={getCustomListHeader()} - listHeaderContent={shouldUseNarrowLayout ? getHeaderText() : null} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} onDismissError={(item) => (item.keyForList ? clearTaxRateError(policyID, item.keyForList, item.pendingAction) : undefined)} From f0db5122e479139d1a8291bd8861df531e8007af Mon Sep 17 00:00:00 2001 From: daledah Date: Wed, 23 Apr 2025 16:30:36 +0700 Subject: [PATCH 08/15] fix: correct search condition in company card page --- src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 27bebcb93d7a0..32fe2d3e43dd4 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -124,7 +124,7 @@ function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDis return ( <> - {sortedCards.length > 0 && ( + {sortedCards.length > 15 && ( Date: Fri, 25 Apr 2025 14:10:18 +0700 Subject: [PATCH 09/15] fix: change component styles and placement --- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- .../WorkspaceCompanyCardsList.tsx | 3 +- .../expensifyCard/WorkspaceCardListHeader.tsx | 74 +--------------- .../expensifyCard/WorkspaceCardListLabels.tsx | 87 +++++++++++++++++++ .../WorkspaceExpensifyCardListPage.tsx | 36 ++++---- 6 files changed, 111 insertions(+), 93 deletions(-) create mode 100644 src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx diff --git a/src/languages/en.ts b/src/languages/en.ts index f27cc7fe339ee..1e6d5cab8c29b 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3833,7 +3833,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 Expensify card', + findCard: 'Find card', newCard: 'New card', name: 'Name', lastFour: 'Last 4', diff --git a/src/languages/es.ts b/src/languages/es.ts index 948ca8acfe96c..73db00f555f3a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3876,7 +3876,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 Tarjetas Expensify', + findCard: 'Encontrar tarjeta', newCard: 'Nueva tarjeta', name: 'Nombre', lastFour: '4 últimos', diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 32fe2d3e43dd4..7f5af7c969cfe 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -124,12 +124,13 @@ function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDis return ( <> - {sortedCards.length > 15 && ( + {sortedCards.length > 0 && ( )} { - if (!isLessThanMediumScreen) { - return ( - <> - - - - - ); - } - return shouldShowSettlementButtonOrDate ? ( - <> - - - - - - - ) : ( - <> - - - - - - - ); - }; - return ( - {getLabelsLayout()} {!!errorMessage && ( - + + + + + + ); + } + return shouldShowSettlementButtonOrDate ? ( + + + + + + + + ) : ( + + + + + + + + ); +} + +WorkspaceCardListLabels.displayName = 'WorkspaceCardListLabels'; +export default WorkspaceCardListLabels; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index 87fe958971cf8..b6617892b4d23 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -35,6 +35,7 @@ import type {Card, WorkspaceCardsList} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import EmptyCardView from './EmptyCardView'; import WorkspaceCardListHeader from './WorkspaceCardListHeader'; +import WorkspaceCardListLabels from './WorkspaceCardListLabels'; import WorkspaceCardListRow from './WorkspaceCardListRow'; type WorkspaceExpensifyCardListPageProps = { @@ -147,19 +148,11 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp [personalDetails, policyCurrency, policyID, workspaceAccountID, styles], ); - const renderListHeader = useCallback( - () => ( - - ), - [policyID, cardSettings], - ); + const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; - const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle(); + const renderListHeader = useCallback(() => , [cardSettings]); - const isSearchEmpty = filteredSortedCards.length === 0 && inputValue.length > 0; + const bottomSafeAreaPaddingStyle = useBottomSafeSafeAreaPaddingStyle(); return ( ) : ( <> - {sortedCards.length > 15 && ( - + - )} + {sortedCards.length > 15 && ( + + )} + Date: Fri, 25 Apr 2025 14:11:47 +0700 Subject: [PATCH 10/15] fix: lint --- src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx index 092266a559c66..64d1675d30948 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx @@ -24,7 +24,7 @@ function WorkspaceCardListLabels({policyID, cardSettings}: WorkspaceCardListLabe const workspaceAccountID = useWorkspaceAccountID(policyID); - const [cardManualBilling] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_MANUAL_BILLING}${workspaceAccountID}`); + const [cardManualBilling] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_MANUAL_BILLING}${workspaceAccountID}`, {canBeMissing: true}); const shouldShowSettlementButtonOrDate = !!cardSettings?.isMonthlySettlementAllowed || cardManualBilling; const isLessThanMediumScreen = isMediumScreenWidth || isSmallScreenWidth; From d69d1546b5517e26e83fed58e78488e408fc815a Mon Sep 17 00:00:00 2001 From: daledah Date: Tue, 29 Apr 2025 03:32:17 +0700 Subject: [PATCH 11/15] fix: add search bar to per diem page --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + .../perDiem/WorkspacePerDiemPage.tsx | 35 +++++++++++++++---- .../WorkspaceReportFieldsPage.tsx | 5 ++- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index bdbaf944f8bf9..f0a4a0e6304c4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3006,6 +3006,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?', diff --git a/src/languages/es.ts b/src/languages/es.ts index c77a734e263b3..485233898ab63 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3033,6 +3033,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?', diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index b0b682f0a8a20..f7dc457ef9c26 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -15,6 +15,7 @@ import * as Illustrations from '@components/Icon/Illustrations'; import LottieAnimations from '@components/LottieAnimations'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import SearchBar from '@components/SearchBar'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; import SelectionListWithModal from '@components/SelectionListWithModal'; @@ -131,6 +132,7 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { const policy = usePolicy(policyID); const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); const {selectionMode} = useMobileSelectionMode(); + const [inputValue, setInputValue] = useState(''); const customUnit = getPerDiemCustomUnit(policy); const customUnitRates: Record = useMemo(() => customUnit?.rates ?? {}, [customUnit]); @@ -139,9 +141,6 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { const allSubRates = getSubRatesData(allRatesArray); - // Filter out rates that will be deleted - const allSelectableSubRates = useMemo(() => allSubRates.filter((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [allSubRates]); - const canSelectMultiple = shouldUseNarrowLayout ? selectionMode?.isEnabled : true; const fetchPerDiem = useCallback(() => { @@ -196,6 +195,23 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex2, styles.alignItemsStart, styles.textSupporting, styles.label, styles.pl2, styles.alignSelfEnd], ); + const filteredSubRatesList = useMemo(() => { + if (!inputValue) { + return subRatesList; + } + const lowerQuery = inputValue.trim().toLowerCase(); + return subRatesList.filter((subRate) => subRate.text?.toLowerCase()?.includes(lowerQuery)); + }, [inputValue, subRatesList]); + + // Filter out rates that will be deleted + const allSelectableSubRates = useMemo(() => { + if (!inputValue) { + return allSubRates.filter((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + } + const lowerQuery = inputValue.trim().toLowerCase(); + return allSubRates.filter((subRate) => subRate.destination?.toLowerCase()?.includes(lowerQuery) && subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + }, [inputValue, allSubRates]); + const toggleSubRate = (subRate: PolicyOption) => { if (selectedPerDiem.find((selectedSubRate) => selectedSubRate.subRateID === subRate.subRateID) !== undefined) { setSelectedPerDiem((prev) => prev.filter((selectedSubRate) => selectedSubRate.subRateID !== subRate.subRateID)); @@ -408,7 +424,15 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { danger /> {shouldUseNarrowLayout && {getHeaderButtons()}} - {(!shouldUseNarrowLayout || !hasVisibleSubRates || isLoading) && getHeaderText()} + {(!hasVisibleSubRates || isLoading) && getHeaderText()} + {subRatesList.length > 15 && ( + + )} {isLoading && ( item && toggleSubRate(item)} - sections={[{data: subRatesList, isDisabled: false}]} + sections={[{data: filteredSubRatesList, isDisabled: false}]} onCheckboxPress={toggleSubRate} onSelectRow={openSubRateDetails} shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} @@ -458,7 +482,6 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { ListItem={TableListItem} customListHeader={getCustomListHeader()} listHeaderWrapperStyle={[styles.ph9, styles.pv3, styles.pb5]} - listHeaderContent={shouldUseNarrowLayout ? getHeaderText() : null} listItemTitleContainerStyles={styles.flex3} showScrollIndicator={false} /> diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index 31b430c41d982..bba934f3ff68d 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -162,7 +162,10 @@ function WorkspaceReportFieldsPage({ }; const toggleAllReportFields = () => { - const availableReportFields = Object.values(filteredPolicyFieldList).filter((reportField) => reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); + const availableReportFields = Object.values(filteredPolicyFieldList).filter( + (reportField) => + reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredReportFieldsSections.at(0)?.data.find((item) => item.fieldID === reportField.fieldID), + ); setSelectedReportFields(selectedReportFields.length > 0 ? [] : availableReportFields); }; From c2c338353d4f2d99bd40a987c5c116f7480ea63c Mon Sep 17 00:00:00 2001 From: daledah Date: Wed, 30 Apr 2025 16:10:34 +0700 Subject: [PATCH 12/15] feat: add useTransition usage to pages --- src/components/SearchBar.tsx | 4 +- src/libs/CardUtils.ts | 9 +++ src/pages/workspace/WorkspaceMembersPage.tsx | 24 +++--- .../categories/WorkspaceCategoriesPage.tsx | 22 +++--- .../WorkspaceCompanyCardsList.tsx | 24 +++--- .../distanceRates/PolicyDistanceRatesPage.tsx | 74 +++++++++--------- .../WorkspaceExpensifyCardListPage.tsx | 25 +++--- .../perDiem/WorkspacePerDiemPage.tsx | 19 +++-- .../ReportFieldsListValuesPage.tsx | 47 ++++++------ .../WorkspaceReportFieldsPage.tsx | 76 +++++++++---------- .../workspace/tags/WorkspaceTagsPage.tsx | 22 +++--- .../workspace/tags/WorkspaceViewTagsPage.tsx | 19 +++-- .../workspace/taxes/WorkspaceTaxesPage.tsx | 71 ++++++++--------- 13 files changed, 227 insertions(+), 209 deletions(-) diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 0f9a289e0f0ae..e0a356c7f5237 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -11,7 +11,7 @@ import Text from './Text'; import TextInput from './TextInput'; type SearchBarProps = { - label?: string; + label: string; icon?: IconAsset; inputValue: string; onChangeText?: (text: string) => void; @@ -45,7 +45,7 @@ function SearchBar({label, style, icon = MagnifyingGlass, inputValue, onChangeTe /> {!!shouldShowEmptyState && inputValue.length !== 0 && ( - + {translate('common.noResultsFoundMatching', {searchString: inputValue})} )} diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 296d32fa1eac1..8d4449924c7c3 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -253,6 +253,14 @@ function sortCardsByCardholderName(cardsList: OnyxEntry, per }); } +function filterCards(searchQuery: string, card: Card, 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 { const feedIcons = { [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: Illustrations.VisaCompanyCardDetailLarge, @@ -619,4 +627,5 @@ export { isExpensifyCardFullySetUp, filterInactiveCards, getFundIdFromSettingsKey, + filterCards, }; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index d2889fbf2ae66..f9529eabfefa7 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState, useTransition} from 'react'; import type {TextInput} from 'react-native'; import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -380,8 +380,8 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const policyOwner = policy?.owner; const currentUserLogin = currentUserPersonalDetails.login; const invitedPrimaryToSecondaryLogins = invertObject(policy?.primaryLoginsInvited ?? {}); - const getUsers = useCallback((): MemberOption[] => { - let result: MemberOption[] = []; + const data: MemberOption[] = useMemo(() => { + const result: MemberOption[] = []; Object.entries(policy?.employeeList ?? {}).forEach(([email, policyEmployee]) => { const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); @@ -443,7 +443,6 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson invitedSecondaryLogin: details?.login ? invitedPrimaryToSecondaryLogins[details.login] ?? '' : '', }); }); - result = sortAlphabetically(result, 'text'); return result; }, [ isOffline, @@ -464,14 +463,17 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson isPolicyAdmin, ]); - const data = useMemo(() => getUsers(), [getUsers]); + const [, startTransition] = useTransition(); + const [filteredData, setFilteredData] = useState([]); - const filteredData = useMemo(() => { - if (!inputValue.trim()) { - return data; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return data.filter((item) => !!item.text?.toLowerCase().includes(lowerQuery) || !!item.alternateText?.toLowerCase().includes(lowerQuery)); + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery + ? data.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.alternateText?.toLowerCase().includes(normalizedSearchQuery)) + : data; + setFilteredData(sortAlphabetically(filtered, 'text')); + }); }, [data, inputValue]); useEffect(() => { diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index f3df3c213d8ee..fd0ea60d0e7b0 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,5 +1,5 @@ import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -52,7 +52,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {PolicyCategory} from '@src/types/onyx'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -141,7 +140,7 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { ); const categoryList = useMemo(() => { - const categories = lodashSortBy(Object.values(policyCategories ?? {}), 'name', localeCompare) as PolicyCategory[]; + const categories = Object.values(policyCategories ?? {}); return categories.reduce((acc, value) => { const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; @@ -170,12 +169,17 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, []); }, [policyCategories, isOffline, selectedCategories, canSelectMultiple, translate, updateWorkspaceRequiresCategory]); - const filteredCategoryList = useMemo(() => { - if (!inputValue.trim()) { - return categoryList; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return categoryList.filter((cat) => cat.text?.toLowerCase().includes(lowerQuery)); + const [, startTransition] = useTransition(); + const [filteredCategoryList, setFilteredCategoryList] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery + ? categoryList.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.alternateText?.toLowerCase().includes(normalizedSearchQuery)) + : categoryList; + setFilteredCategoryList(lodashSortBy(Object.values(filtered ?? {}), 'text', localeCompare) as PolicyOption[]); + }); }, [inputValue, categoryList]); useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredCategoryList); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index 7f5af7c969cfe..f9e94f482b45a 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -9,7 +9,7 @@ import SearchBar from '@components/SearchBar'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getDefaultCardName, sortCardsByCardholderName} from '@libs/CardUtils'; +import {filterCards, getDefaultCardName, sortCardsByCardholderName} from '@libs/CardUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -41,17 +41,15 @@ function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDis const sortedCards = useMemo(() => sortCardsByCardholderName(cardsList, personalDetails), [cardsList, personalDetails]); const [inputValue, setInputValue] = useState(''); - const filteredSortedCards = useMemo(() => { - if (!inputValue) { - return sortedCards; - } - const lowerValue = inputValue.toLowerCase(); - return sortedCards.filter((card) => { - 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(lowerValue) || lastFourPAN.includes(lowerValue) || accountLogin.includes(lowerValue) || accountName.includes(lowerValue); + + const [, startTransition] = useTransition(); + const [filteredSortedCards, setFilteredSortedCards] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? sortedCards.filter((card) => filterCards(normalizedSearchQuery, card, personalDetails)) : sortedCards; + setFilteredSortedCards(filtered); }); }, [inputValue, personalDetails, sortedCards]); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 3acf5aa77405d..bd5d6200bd405 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import Button from '@components/Button'; import type {DropdownOption, WorkspaceDistanceRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; @@ -45,7 +45,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Rate} from '@src/types/onyx/Policy'; -type RateForList = ListItem & {value: string}; +type RateForList = ListItem & {value: string; rate?: number}; type PolicyDistanceRatesPageProps = PlatformStackScreenProps; @@ -148,43 +148,45 @@ function PolicyDistanceRatesPage({ const distanceRatesList = useMemo( () => - Object.values(customUnitRates) - .sort((rateA, rateB) => (rateA?.rate ?? 0) - (rateB?.rate ?? 0)) - .map((value) => ({ - value: value.customUnitRateID, - text: `${convertAmountToDisplayString(value.rate, value.currency ?? CONST.CURRENCY.USD)} / ${translate( - `common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`, - )}`, - keyForList: value.customUnitRateID, - isSelected: selectedDistanceRates.find((rate) => rate.customUnitRateID === value.customUnitRateID) !== undefined && canSelectMultiple, - isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - pendingAction: - value.pendingAction ?? - value.pendingFields?.rate ?? - value.pendingFields?.enabled ?? - value.pendingFields?.currency ?? - value.pendingFields?.taxRateExternalID ?? - value.pendingFields?.taxClaimablePercentage ?? - (policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined), - errors: value.errors ?? undefined, - rightElement: ( - updateDistanceRateEnabled(newValue, value.customUnitRateID)} - disabled={value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} - /> - ), - })), + Object.values(customUnitRates).map((value) => ({ + rate: value.rate, + value: value.customUnitRateID, + text: `${convertAmountToDisplayString(value.rate, value.currency ?? CONST.CURRENCY.USD)} / ${translate( + `common.${customUnit?.attributes?.unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES}`, + )}`, + keyForList: value.customUnitRateID, + isSelected: selectedDistanceRates.find((rate) => rate.customUnitRateID === value.customUnitRateID) !== undefined && canSelectMultiple, + isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingAction: + value.pendingAction ?? + value.pendingFields?.rate ?? + value.pendingFields?.enabled ?? + value.pendingFields?.currency ?? + value.pendingFields?.taxRateExternalID ?? + value.pendingFields?.taxClaimablePercentage ?? + (policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD ? policy?.pendingAction : undefined), + errors: value.errors ?? undefined, + rightElement: ( + updateDistanceRateEnabled(newValue, value.customUnitRateID)} + disabled={value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE} + /> + ), + })), [customUnitRates, translate, customUnit, selectedDistanceRates, canSelectMultiple, policy?.pendingAction, updateDistanceRateEnabled], ); - const filteredDistanceRatesList = useMemo(() => { - if (!inputValue.trim()) { - return distanceRatesList; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return distanceRatesList.filter((rate) => rate.text?.toLowerCase().includes(lowerQuery)); + const [, startTransition] = useTransition(); + const [filteredDistanceRatesList, setFilteredDistanceRatesList] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? distanceRatesList.filter((rate) => rate.text?.toLowerCase().includes(normalizedSearchQuery)) : distanceRatesList; + setFilteredDistanceRatesList(filtered.sort((rateA, rateB) => (rateA?.rate ?? 0) - (rateB?.rate ?? 0))); + }); }, [distanceRatesList, inputValue]); const hasVisibleRates = useMemo(() => Object.values(customUnitRates).some((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [customUnitRates]); diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx index b6617892b4d23..a2ed5e6fef90f 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardListPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -21,7 +21,7 @@ import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearDeletePaymentMethodError} from '@libs/actions/PaymentMethods'; -import {sortCardsByCardholderName} from '@libs/CardUtils'; +import {filterCards, sortCardsByCardholderName} from '@libs/CardUtils'; import goBackFromWorkspaceCentralScreen from '@libs/Navigation/helpers/goBackFromWorkspaceCentralScreen'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDescriptionForPolicyDomainCard} from '@libs/PolicyUtils'; @@ -77,19 +77,16 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp const [inputValue, setInputValue] = useState(''); - const filteredSortedCards = useMemo(() => { - if (!inputValue) { - return sortedCards; - } - const lowerValue = inputValue.toLowerCase(); - return sortedCards.filter((card) => { - 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(lowerValue) || lastFourPAN.includes(lowerValue) || accountLogin.includes(lowerValue) || accountName.includes(lowerValue); + const [, startTransition] = useTransition(); + const [filteredSortedCards, setFilteredSortedCards] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? sortedCards.filter((card) => filterCards(normalizedSearchQuery, card, personalDetails)) : sortedCards; + setFilteredSortedCards(filtered); }); - }, [inputValue, sortedCards, personalDetails]); + }, [inputValue, personalDetails, sortedCards]); const handleIssueCardPress = () => { if (isActingAsDelegate) { diff --git a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx index fb74a98036f11..21450dd04966b 100644 --- a/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx +++ b/src/pages/workspace/perDiem/WorkspacePerDiemPage.tsx @@ -1,6 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -160,7 +160,7 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { const subRatesList = useMemo( () => - (lodashSortBy(allSubRates, 'destination', localeCompare) as SubRateData[]).map((value) => { + allSubRates.map((value) => { const isDisabled = value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; return { text: value.destination, @@ -195,12 +195,15 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex2, styles.alignItemsStart, styles.textSupporting, styles.label, styles.pl2, styles.alignSelfEnd], ); - const filteredSubRatesList = useMemo(() => { - if (!inputValue) { - return subRatesList; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return subRatesList.filter((subRate) => subRate.text?.toLowerCase()?.includes(lowerQuery)); + const [, startTransition] = useTransition(); + const [filteredSubRatesList, setFilteredSubRatesList] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? subRatesList.filter((subRate) => subRate.text?.toLowerCase()?.includes(normalizedSearchQuery)) : subRatesList; + setFilteredSubRatesList(lodashSortBy(filtered, 'text', localeCompare) as PolicyOption[]); + }); }, [inputValue, subRatesList]); // Filter out rates that will be deleted diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index 401108963012a..8496ebc55f8c5 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -119,9 +119,9 @@ function ReportFieldsListValuesPage({ onNavigationCallBack: () => Navigation.goBack(), }); - const listValuesSections = useMemo(() => { - const data = listValues - .map((value, index) => ({ + const data = useMemo( + () => + listValues.map((value, index) => ({ value, index, text: value, @@ -135,25 +135,22 @@ function ReportFieldsListValuesPage({ onToggle={(newValue: boolean) => updateReportFieldListValueEnabled(newValue, index)} /> ), - })) - .sort((a, b) => localeCompare(a.value, b.value)); - return [{data, isDisabled: false}]; - }, [canSelectMultiple, disabledListValues, listValues, selectedValues, translate, updateReportFieldListValueEnabled]); - - const filteredListValuesSections = useMemo(() => { - if (!inputValue.trim()) { - return listValuesSections; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return [ - { - data: listValuesSections.at(0)?.data.filter((reportField) => reportField.text?.toLowerCase().includes(lowerQuery)) ?? [], - isDisabled: listValuesSections?.at(0)?.isDisabled, - }, - ]; - }, [listValuesSections, inputValue]); + })), + [canSelectMultiple, disabledListValues, listValues, selectedValues, translate, updateReportFieldListValueEnabled], + ); + + const [, startTransition] = useTransition(); + const [filteredListValuesSections, setFilteredListValuesSections] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? data.filter((reportField) => reportField.text?.toLowerCase().includes(normalizedSearchQuery)) : data; + setFilteredListValuesSections(filtered.sort((a, b) => localeCompare(a.value, b.value))); + }); + }, [data, inputValue]); - const filteredListValuesArray = (filteredListValuesSections.at(0)?.data ?? []).map((item) => item.value); + const filteredListValuesArray = filteredListValuesSections.map((item) => item.value); const shouldShowEmptyState = Object.values(listValues ?? {}).length <= 0; const selectedValuesArray = Object.keys(selectedValues).filter((key) => selectedValues[key]); @@ -343,12 +340,12 @@ function ReportFieldsListValuesPage({ {translate('workspace.reportFields.listInputSubtitle')} - {(listValuesSections.at(0)?.data?.length ?? 0) > 15 && ( + {filteredListValuesSections.length > 15 && ( )} {shouldShowEmptyState && ( @@ -370,7 +367,7 @@ function ReportFieldsListValuesPage({ canSelectMultiple={canSelectMultiple} turnOnSelectionModeOnLongPress={!hasAccountingConnections} onTurnOnSelectionMode={(item) => item && toggleValue(item)} - sections={filteredListValuesSections} + sections={[{data: filteredListValuesSections, isDisabled: false}]} onCheckboxPress={toggleValue} onSelectRow={openListValuePage} onSelectAll={toggleAllValues} diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index bba934f3ff68d..acd5cde518e82 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -1,6 +1,6 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -113,45 +113,35 @@ function WorkspaceReportFieldsPage({ setSelectedReportFields([]); }, [isFocused]); - const reportFieldsSections = useMemo(() => { + const data = useMemo(() => { if (!policy) { - return [{data: [], isDisabled: true}]; + return []; } + return Object.values(filteredPolicyFieldList).map((reportField) => ({ + value: reportField.name, + fieldID: reportField.fieldID, + keyForList: String(reportField.fieldID), + orderWeight: reportField.orderWeight, + pendingAction: reportField.pendingAction, + isSelected: selectedReportFields.find((selectedReportField) => selectedReportField.name === reportField.name) !== undefined && canSelectMultiple, + isDisabled: reportField.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + text: reportField.name, + rightElement: , + })); + }, [canSelectMultiple, filteredPolicyFieldList, policy, selectedReportFields, translate]); - return [ - { - data: Object.values(filteredPolicyFieldList) - .sort((a, b) => localeCompare(a.name, b.name)) - .map((reportField) => ({ - value: reportField.name, - fieldID: reportField.fieldID, - keyForList: String(reportField.fieldID), - orderWeight: reportField.orderWeight, - pendingAction: reportField.pendingAction, - isSelected: selectedReportFields.find((selectedReportField) => selectedReportField.name === reportField.name) !== undefined && canSelectMultiple, - isDisabled: reportField.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - text: reportField.name, - rightElement: , - })), - isDisabled: false, - }, - ]; - }, [filteredPolicyFieldList, policy, selectedReportFields, canSelectMultiple, translate]); + const [, startTransition] = useTransition(); + const [filteredReportFieldsSections, setFilteredReportFieldsSections] = useState([]); - const filteredReportFieldsSections = useMemo(() => { - if (!inputValue.trim()) { - return reportFieldsSections; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return [ - { - data: reportFieldsSections.at(0)?.data.filter((reportField) => reportField.text.toLowerCase().includes(lowerQuery)) ?? [], - isDisabled: reportFieldsSections?.at(0)?.isDisabled, - }, - ]; - }, [reportFieldsSections, inputValue]); + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? data.filter((reportField) => reportField.text.toLowerCase().includes(normalizedSearchQuery)) : data; + setFilteredReportFieldsSections(filtered.sort((a, b) => localeCompare(a.value, b.value))); + }); + }, [inputValue, data]); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredReportFieldsSections.at(0)?.data ?? ([] as ListItem[])); + useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredReportFieldsSections); const updateSelectedReportFields = (item: ReportFieldForList) => { const fieldKey = getReportFieldKey(item.fieldID); @@ -163,8 +153,7 @@ function WorkspaceReportFieldsPage({ const toggleAllReportFields = () => { const availableReportFields = Object.values(filteredPolicyFieldList).filter( - (reportField) => - reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredReportFieldsSections.at(0)?.data.find((item) => item.fieldID === reportField.fieldID), + (reportField) => reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredReportFieldsSections.find((item) => item.fieldID === reportField.fieldID), ); setSelectedReportFields(selectedReportFields.length > 0 ? [] : availableReportFields); }; @@ -294,12 +283,12 @@ function WorkspaceReportFieldsPage({ /> {(!shouldUseNarrowLayout || !hasVisibleReportField || isLoading) && getHeaderText()} {!shouldShowEmptyState && !isLoading && shouldUseNarrowLayout && getHeaderText()} - {(reportFieldsSections.at(0)?.data?.length ?? 0) > 15 && ( + {filteredReportFieldsSections.length > 15 && ( )} {isLoading && ( @@ -309,7 +298,7 @@ function WorkspaceReportFieldsPage({ color={theme.spinner} /> )} - {shouldShowEmptyState && filteredReportFieldsSections.at(0)?.data.length === 0 && ( + {shouldShowEmptyState && filteredReportFieldsSections.length === 0 && ( item && updateSelectedReportFields(item)} - sections={filteredReportFieldsSections} + sections={[ + { + data: filteredReportFieldsSections, + isDisabled: !policy, + }, + ]} onCheckboxPress={updateSelectedReportFields} onSelectRow={navigateToReportFieldsSettings} onSelectAll={toggleAllReportFields} diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 6a93ca1da2ad7..53feeb6173c32 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -1,5 +1,5 @@ import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -190,8 +190,7 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { }; }); } - const sortedTags = lodashSortBy(Object.values(policyTagLists.at(0)?.tags ?? {}), 'name', localeCompare) as PolicyTag[]; - return sortedTags.map((tag) => ({ + return Object.values(policyTagLists?.at(0)?.tags ?? {}).map((tag) => ({ value: tag.name, text: getCleanedTagName(tag.name), keyForList: tag.name, @@ -211,12 +210,17 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { })); }, [isMultiLevelTags, policyTagLists, selectedTags, canSelectMultiple, translate, updateWorkspaceRequiresTag, updateWorkspaceTagEnabled]); - const filteredTagList = useMemo(() => { - if (!inputValue.trim()) { - return tagList; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return tagList.filter((item) => !!item.text?.toLowerCase().includes(lowerQuery) || !!item.value?.toLowerCase().includes(lowerQuery)); + const [, startTransition] = useTransition(); + const [filteredTagList, setFilteredTagList] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery + ? tagList.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.value?.toLowerCase().includes(normalizedSearchQuery)) + : tagList; + setFilteredTagList(lodashSortBy(filtered, 'value', localeCompare) as TagListItem[]); + }); }, [tagList, inputValue]); const filteredTagListKeyedByName = useMemo( diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index 7958d85454d02..59c7a02739bab 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -130,12 +130,17 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { [currentPolicyTag?.tags, selectedTags, canSelectMultiple, translate, updateWorkspaceTagEnabled], ); - const filteredTagList = useMemo(() => { - if (!inputValue.trim()) { - return tagList; - } - const lowerQuery = inputValue.trim().toLowerCase(); - return tagList.filter((item) => !!item.text?.toLowerCase().includes(lowerQuery) || !!item.value?.toLowerCase().includes(lowerQuery)); + const [, startTransition] = useTransition(); + const [filteredTagList, setFilteredTagList] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery + ? tagList.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.value?.toLowerCase().includes(normalizedSearchQuery)) + : tagList; + setFilteredTagList(filtered.sort((tagA, tagB) => localeCompare(tagA.value, tagB.value))); + }); }, [tagList, inputValue]); const hasDependentTags = useMemo(() => hasDependentTagsPolicyUtils(policy, policyTags), [policy, policyTags]); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 1734c53e7e597..2c5f79efcfe99 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -159,41 +159,44 @@ function WorkspaceTaxesPage({ if (!policy) { return []; } - return Object.entries(policy.taxRates?.taxes ?? {}) - .map(([key, value]) => { - const canEditTaxRate = policy && canEditTaxRatePolicyUtils(policy, key); - - return { - text: value.name, - alternateText: textForDefault(key, value), - keyForList: key, - isSelected: !!selectedTaxesIDs.includes(key) && canSelectMultiple, - isDisabledCheckbox: !canEditTaxRatePolicyUtils(policy, key), - isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), - errors: value.errors ?? getLatestErrorFieldForAnyField(value), - rightElement: ( - updateWorkspaceTaxEnabled(newValue, key)} - /> - ), - }; - }) - .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); + return Object.entries(policy.taxRates?.taxes ?? {}).map(([key, value]) => { + const canEditTaxRate = policy && canEditTaxRatePolicyUtils(policy, key); + + return { + text: value.name, + alternateText: textForDefault(key, value), + keyForList: key, + isSelected: !!selectedTaxesIDs.includes(key) && canSelectMultiple, + isDisabledCheckbox: !canEditTaxRatePolicyUtils(policy, key), + isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), + errors: value.errors ?? getLatestErrorFieldForAnyField(value), + rightElement: ( + updateWorkspaceTaxEnabled(newValue, key)} + /> + ), + }; + }); }, [policy, textForDefault, selectedTaxesIDs, canSelectMultiple, translate, updateWorkspaceTaxEnabled]); - const filteredTaxesList = useMemo(() => { - if (!inputValue) { - return taxesList; - } - const lowerQuery = inputValue.toLowerCase(); - return taxesList.filter((tax) => { - const taxName = tax.text?.toLowerCase() ?? ''; - const taxAlternateText = tax.alternateText?.toLowerCase() ?? ''; - return taxName.includes(lowerQuery) || taxAlternateText.includes(lowerQuery); + const [, startTransition] = useTransition(); + const [filteredTaxesList, setFilteredTaxesList] = useState([]); + + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery + ? taxesList.filter((tax) => { + const taxName = tax.text?.toLowerCase() ?? ''; + const taxAlternateText = tax.alternateText?.toLowerCase() ?? ''; + return taxName.includes(normalizedSearchQuery) || taxAlternateText.includes(normalizedSearchQuery); + }) + : taxesList; + setFilteredTaxesList(filtered.sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? ''))); }); }, [inputValue, taxesList]); From 36290414ca3a03d9f48f420307b7e064debd4676 Mon Sep 17 00:00:00 2001 From: daledah Date: Sat, 3 May 2025 00:50:25 +0700 Subject: [PATCH 13/15] fix: migrate useTransition to a new hook --- src/hooks/useSearchResults.ts | 23 +++++++++++++ src/libs/CardUtils.ts | 27 ++++++++-------- src/pages/workspace/WorkspaceMembersPage.tsx | 22 +++++-------- .../categories/WorkspaceCategoriesPage.tsx | 23 ++++++------- .../WorkspaceCompanyCardsList.tsx | 32 +++++++------------ .../distanceRates/PolicyDistanceRatesPage.tsx | 21 ++++-------- .../expensifyCard/WorkspaceCardListLabels.tsx | 2 +- .../WorkspaceExpensifyCardListPage.tsx | 30 +++++++---------- .../perDiem/WorkspacePerDiemPage.tsx | 32 ++++++------------- .../ReportFieldsListValuesPage.tsx | 26 ++++++--------- .../WorkspaceReportFieldsPage.tsx | 30 +++++++---------- .../workspace/tags/WorkspaceTagsPage.tsx | 20 +++--------- .../workspace/tags/WorkspaceViewTagsPage.tsx | 19 +++-------- .../workspace/taxes/WorkspaceTaxesPage.tsx | 31 ++++++++---------- 14 files changed, 138 insertions(+), 200 deletions(-) create mode 100644 src/hooks/useSearchResults.ts diff --git a/src/hooks/useSearchResults.ts b/src/hooks/useSearchResults.ts new file mode 100644 index 0000000000000..5bd5d85cd100b --- /dev/null +++ b/src/hooks/useSearchResults.ts @@ -0,0 +1,23 @@ +import {useEffect, useState, useTransition} from 'react'; + +/** + * 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(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(); + useEffect(() => { + startTransition(() => { + const normalizedSearchQuery = inputValue.trim().toLowerCase(); + const filtered = normalizedSearchQuery ? data.filter((item) => filterData(item, inputValue)) : data; + const sorted = sortData(filtered); + setResult(sorted); + }); + }, [data, filterData, inputValue, sortData]); + return [inputValue, setInputValue, result] as const; +} + +export default useSearchResults; diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 8d5ed7d6ad853..b24531ef20506 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -240,22 +240,22 @@ function getEligibleBankAccountsForCard(bankAccountsList: OnyxEntry bankAccount?.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && bankAccount?.accountData?.allowDebit); } -function sortCardsByCardholderName(cardsList: OnyxEntry, personalDetails: OnyxEntry, policyMembersAccountIDs: number[]): Card[] { +function getCardsByCardholderName(cardsList: OnyxEntry, 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] ?? {} : {}; - - const aName = getDisplayNameOrDefault(userA); - const bName = getDisplayNameOrDefault(userB); + return Object.values(cards).filter((card: Card) => card.accountID && policyMembersAccountIDs.includes(card.accountID)); +} - return localeCompare(aName, bName); - }); +function sortCardsByCardholderName(cards: Card[], personalDetails: OnyxEntry): 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); + }); } -function filterCards(searchQuery: string, card: Card, personalDetails?: PersonalDetailsList) { +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() ?? ''; @@ -629,5 +629,6 @@ export { isExpensifyCardFullySetUp, filterInactiveCards, getFundIdFromSettingsKey, - filterCards, + getCardsByCardholderName, + filterCardsByPersonalDetails, }; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index f9529eabfefa7..964999ecdb21a 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -1,6 +1,6 @@ import {useIsFocused} from '@react-navigation/native'; import lodashIsEqual from 'lodash/isEqual'; -import React, {useCallback, useEffect, useMemo, useRef, useState, useTransition} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {TextInput} from 'react-native'; import {InteractionManager, View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; @@ -29,6 +29,7 @@ import useNetwork from '@hooks/useNetwork'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; +import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; import useThreeDotsAnchorPosition from '@hooks/useThreeDotsAnchorPosition'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -100,7 +101,6 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline; const prevPersonalDetails = usePrevious(personalDetails); const {translate, formatPhoneNumber, preferredLocale} = useLocalize(); - const [inputValue, setInputValue] = useState(''); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -463,18 +463,12 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson isPolicyAdmin, ]); - const [, startTransition] = useTransition(); - const [filteredData, setFilteredData] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery - ? data.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.alternateText?.toLowerCase().includes(normalizedSearchQuery)) - : data; - setFilteredData(sortAlphabetically(filtered, 'text')); - }); - }, [data, inputValue]); + const filterMember = useCallback( + (memberOption: MemberOption, searchQuery: string) => !!memberOption.text?.toLowerCase().includes(searchQuery) || !!memberOption.alternateText?.toLowerCase().includes(searchQuery), + [], + ); + const sortMembers = useCallback((memberOptions: MemberOption[]) => sortAlphabetically(memberOptions, 'text'), []); + const [inputValue, setInputValue, filteredData] = useSearchResults(data, filterMember, sortMembers); useEffect(() => { if (!isFocused) { diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index fd0ea60d0e7b0..fec2bfd7e61e4 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -1,5 +1,5 @@ import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -33,6 +33,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; +import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThreeDotsAnchorPosition from '@hooks/useThreeDotsAnchorPosition'; @@ -87,7 +88,6 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { const isConnectionVerified = connectedIntegration && !isConnectionUnverified(policy, connectedIntegration); const currentConnectionName = getCurrentConnectionName(policy); const isQuickSettingsFlow = !!backTo; - const [inputValue, setInputValue] = useState(''); const {canUseLeftHandBar} = usePermissions(); const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true; @@ -169,18 +169,13 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { }, []); }, [policyCategories, isOffline, selectedCategories, canSelectMultiple, translate, updateWorkspaceRequiresCategory]); - const [, startTransition] = useTransition(); - const [filteredCategoryList, setFilteredCategoryList] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery - ? categoryList.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.alternateText?.toLowerCase().includes(normalizedSearchQuery)) - : categoryList; - setFilteredCategoryList(lodashSortBy(Object.values(filtered ?? {}), 'text', localeCompare) as PolicyOption[]); - }); - }, [inputValue, categoryList]); + const filterCategory = useCallback((categoryOption: PolicyOption, searchInput: string) => { + return !!categoryOption.text?.toLowerCase().includes(searchInput) || !!categoryOption.alternateText?.toLowerCase().includes(searchInput); + }, []); + const sortCategories = useCallback((data: PolicyOption[]) => { + return lodashSortBy(data, 'text', localeCompare) as PolicyOption[]; + }, []); + const [inputValue, setInputValue, filteredCategoryList] = useSearchResults(categoryList, filterCategory, sortCategories); useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredCategoryList); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx index a1f114a5e0ebc..639ae4e73fc7a 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsList.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; +import React, {useCallback, useMemo} from 'react'; import type {ListRenderItemInfo} from 'react-native'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; @@ -9,8 +9,9 @@ import SearchBar from '@components/SearchBar'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import usePolicy from '@hooks/usePolicy'; +import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; -import {filterCards, getDefaultCardName, sortCardsByCardholderName} from '@libs/CardUtils'; +import {filterCardsByPersonalDetails, getCardsByCardholderName, getDefaultCardName, sortCardsByCardholderName} from '@libs/CardUtils'; import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; @@ -41,23 +42,14 @@ function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDis const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES, {canBeMissing: true}); const policy = usePolicy(policyID); - const sortedCards = useMemo( - () => sortCardsByCardholderName(cardsList, personalDetails, Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList))), - [cardsList, personalDetails, policy?.employeeList], - ); - - const [inputValue, setInputValue] = useState(''); - - const [, startTransition] = useTransition(); - const [filteredSortedCards, setFilteredSortedCards] = useState([]); + const allCards = useMemo(() => { + const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); + return getCardsByCardholderName(cardsList, policyMembersAccountIDs); + }, [cardsList, policy?.employeeList]); - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery ? sortedCards.filter((card) => filterCards(normalizedSearchQuery, card, personalDetails)) : sortedCards; - setFilteredSortedCards(filtered); - }); - }, [inputValue, personalDetails, sortedCards]); + const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); + const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails), [personalDetails]); + const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); const renderItem = useCallback( ({item, index}: ListRenderItemInfo) => { @@ -115,7 +107,7 @@ function WorkspaceCompanyCardsList({cardsList, policyID, handleAssignCard, isDis [styles, translate], ); - if (sortedCards.length === 0) { + if (allCards.length === 0) { return ( - {sortedCards.length > 0 && ( + {allCards.length > 0 && ( ([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery ? distanceRatesList.filter((rate) => rate.text?.toLowerCase().includes(normalizedSearchQuery)) : distanceRatesList; - setFilteredDistanceRatesList(filtered.sort((rateA, rateB) => (rateA?.rate ?? 0) - (rateB?.rate ?? 0))); - }); - }, [distanceRatesList, inputValue]); - - const hasVisibleRates = useMemo(() => Object.values(customUnitRates).some((rate) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE), [customUnitRates]); + const filterRate = useCallback((rate: RateForList, searchInput: string) => !!rate.text?.toLowerCase().includes(searchInput.toLowerCase()), []); + const sortRates = useCallback((rates: RateForList[]) => rates.sort((a, b) => (a.rate ?? 0) - (b.rate ?? 0)), []); + const [inputValue, setInputValue, filteredDistanceRatesList] = useSearchResults(distanceRatesList, filterRate, sortRates); const addRate = () => { Navigation.navigate(ROUTES.WORKSPACE_CREATE_DISTANCE_RATE.getRoute(policyID)); @@ -383,7 +374,7 @@ function PolicyDistanceRatesPage({ label={translate('workspace.distanceRates.findRate')} inputValue={inputValue} onChangeText={setInputValue} - shouldShowEmptyState={hasVisibleRates && filteredDistanceRatesList.length === 0} + shouldShowEmptyState={filteredDistanceRatesList.length === 0} /> )} {isLoading && ( diff --git a/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx b/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx index 64d1675d30948..41171a4da0b62 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceCardListLabels.tsx @@ -52,7 +52,7 @@ function WorkspaceCardListLabels({policyID, cardSettings}: WorkspaceCardListLabe type={CONST.WORKSPACE_CARDS_LIST_LABEL_TYPE.CURRENT_BALANCE} value={cardSettings?.[CONST.WORKSPACE_CARDS_LIST_LABEL_TYPE.CURRENT_BALANCE] ?? 0} /> - + policy?.outputCurrency ?? CONST.CURRENCY.USD, [policy]); - const sortedCards = useMemo( - () => sortCardsByCardholderName(cardsList, personalDetails, Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList))), - [cardsList, personalDetails, policy?.employeeList], - ); - - const [inputValue, setInputValue] = useState(''); - - const [, startTransition] = useTransition(); - const [filteredSortedCards, setFilteredSortedCards] = useState([]); + const allCards = useMemo(() => { + const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); + return getCardsByCardholderName(cardsList, policyMembersAccountIDs); + }, [cardsList, policy?.employeeList]); - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery ? sortedCards.filter((card) => filterCards(normalizedSearchQuery, card, personalDetails)) : sortedCards; - setFilteredSortedCards(filtered); - }); - }, [inputValue, personalDetails, sortedCards]); + const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); + const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails), [personalDetails]); + const [inputValue, setInputValue, filteredSortedCards] = useSearchResults(allCards, filterCard, sortCards); const handleIssueCardPress = () => { if (isActingAsDelegate) { @@ -192,7 +184,7 @@ function WorkspaceExpensifyCardListPage({route, cardsList, fundID}: WorkspaceExp policyID={policyID} cardSettings={cardSettings} /> - {sortedCards.length > 15 && ( + {allCards.length > 15 && ( = useMemo(() => customUnit?.rates ?? {}, [customUnit]); @@ -195,25 +195,9 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { [allSubRates, selectedPerDiem, canSelectMultiple, styles.flex2, styles.alignItemsStart, styles.textSupporting, styles.label, styles.pl2, styles.alignSelfEnd], ); - const [, startTransition] = useTransition(); - const [filteredSubRatesList, setFilteredSubRatesList] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery ? subRatesList.filter((subRate) => subRate.text?.toLowerCase()?.includes(normalizedSearchQuery)) : subRatesList; - setFilteredSubRatesList(lodashSortBy(filtered, 'text', localeCompare) as PolicyOption[]); - }); - }, [inputValue, subRatesList]); - - // Filter out rates that will be deleted - const allSelectableSubRates = useMemo(() => { - if (!inputValue) { - return allSubRates.filter((subRate) => subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - } - const lowerQuery = inputValue.trim().toLowerCase(); - return allSubRates.filter((subRate) => subRate.destination?.toLowerCase()?.includes(lowerQuery) && subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); - }, [inputValue, allSubRates]); + const filterRate = useCallback((rate: PolicyOption, searchInput: string) => !!rate.text?.toLowerCase().includes(searchInput), []); + const sortRates = useCallback((rates: PolicyOption[]) => lodashSortBy(rates, 'text', localeCompare) as PolicyOption[], []); + const [inputValue, setInputValue, filteredSubRatesList] = useSearchResults(subRatesList, filterRate, sortRates); const toggleSubRate = (subRate: PolicyOption) => { if (selectedPerDiem.find((selectedSubRate) => selectedSubRate.subRateID === subRate.subRateID) !== undefined) { @@ -231,7 +215,11 @@ function WorkspacePerDiemPage({route}: WorkspacePerDiemPageProps) { if (selectedPerDiem.length > 0) { setSelectedPerDiem([]); } else { - setSelectedPerDiem([...allSelectableSubRates]); + const availablePerDiemRates = allSubRates.filter( + (subRate) => + subRate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredSubRatesList.some((filteredSubRate) => filteredSubRate.subRateID === subRate.subRateID), + ); + setSelectedPerDiem(availablePerDiemRates); } }; diff --git a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx index 8496ebc55f8c5..ebc4f847a4e46 100644 --- a/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx +++ b/src/pages/workspace/reportFields/ReportFieldsListValuesPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -23,6 +23,7 @@ import useLocalize from '@hooks/useLocalize'; import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; +import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; import { @@ -98,8 +99,6 @@ function ReportFieldsListValuesPage({ return [reportFieldValues, reportFieldDisabledValues]; }, [formDraft?.disabledListValues, formDraft?.listValues, policy?.fieldList, reportFieldID]); - const [inputValue, setInputValue] = useState(''); - const updateReportFieldListValueEnabled = useCallback( (value: boolean, valueIndex: number) => { if (reportFieldID) { @@ -139,18 +138,11 @@ function ReportFieldsListValuesPage({ [canSelectMultiple, disabledListValues, listValues, selectedValues, translate, updateReportFieldListValueEnabled], ); - const [, startTransition] = useTransition(); - const [filteredListValuesSections, setFilteredListValuesSections] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery ? data.filter((reportField) => reportField.text?.toLowerCase().includes(normalizedSearchQuery)) : data; - setFilteredListValuesSections(filtered.sort((a, b) => localeCompare(a.value, b.value))); - }); - }, [data, inputValue]); + const filterListValue = useCallback((item: ValueListItem, searchInput: string) => !!item.text?.toLowerCase().includes(searchInput.toLowerCase()), []); + const sortListValues = useCallback((values: ValueListItem[]) => values.sort((a, b) => localeCompare(a.value, b.value)), []); + const [inputValue, setInputValue, filteredListValues] = useSearchResults(data, filterListValue, sortListValues); - const filteredListValuesArray = filteredListValuesSections.map((item) => item.value); + const filteredListValuesArray = filteredListValues.map((item) => item.value); const shouldShowEmptyState = Object.values(listValues ?? {}).length <= 0; const selectedValuesArray = Object.keys(selectedValues).filter((key) => selectedValues[key]); @@ -340,12 +332,12 @@ function ReportFieldsListValuesPage({ {translate('workspace.reportFields.listInputSubtitle')} - {filteredListValuesSections.length > 15 && ( + {filteredListValues.length > 15 && ( )} {shouldShowEmptyState && ( @@ -367,7 +359,7 @@ function ReportFieldsListValuesPage({ canSelectMultiple={canSelectMultiple} turnOnSelectionModeOnLongPress={!hasAccountingConnections} onTurnOnSelectionMode={(item) => item && toggleValue(item)} - sections={[{data: filteredListValuesSections, isDisabled: false}]} + sections={[{data: filteredListValues, isDisabled: false}]} onCheckboxPress={toggleValue} onSelectRow={openListValuePage} onSelectAll={toggleAllValues} diff --git a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx index acd5cde518e82..fdb51c11475cd 100644 --- a/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx +++ b/src/pages/workspace/reportFields/WorkspaceReportFieldsPage.tsx @@ -1,6 +1,6 @@ import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -30,6 +30,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; @@ -92,8 +93,6 @@ function WorkspaceReportFieldsPage({ const isConnectionVerified = connectedIntegration && !isConnectionUnverified(policy, connectedIntegration); const currentConnectionName = getCurrentConnectionName(policy); - const [inputValue, setInputValue] = useState(''); - const canSelectMultiple = !hasReportAccountingConnections && (isSmallScreenWidth ? selectionMode?.isEnabled : true); const fetchReportFields = useCallback(() => { @@ -130,18 +129,11 @@ function WorkspaceReportFieldsPage({ })); }, [canSelectMultiple, filteredPolicyFieldList, policy, selectedReportFields, translate]); - const [, startTransition] = useTransition(); - const [filteredReportFieldsSections, setFilteredReportFieldsSections] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery ? data.filter((reportField) => reportField.text.toLowerCase().includes(normalizedSearchQuery)) : data; - setFilteredReportFieldsSections(filtered.sort((a, b) => localeCompare(a.value, b.value))); - }); - }, [inputValue, data]); + const filterReportField = useCallback((reportField: ReportFieldForList, searchInput: string) => !!reportField.text?.toLowerCase().includes(searchInput), []); + const sortReportFields = useCallback((reportFields: ReportFieldForList[]) => reportFields.sort((a, b) => localeCompare(a.value, b.value)), []); + const [inputValue, setInputValue, filteredReportFields] = useSearchResults(data, filterReportField, sortReportFields); - useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredReportFieldsSections); + useAutoTurnSelectionModeOffWhenHasNoActiveOption(filteredReportFields); const updateSelectedReportFields = (item: ReportFieldForList) => { const fieldKey = getReportFieldKey(item.fieldID); @@ -153,7 +145,7 @@ function WorkspaceReportFieldsPage({ const toggleAllReportFields = () => { const availableReportFields = Object.values(filteredPolicyFieldList).filter( - (reportField) => reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredReportFieldsSections.find((item) => item.fieldID === reportField.fieldID), + (reportField) => reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredReportFields.find((item) => item.fieldID === reportField.fieldID), ); setSelectedReportFields(selectedReportFields.length > 0 ? [] : availableReportFields); }; @@ -283,12 +275,12 @@ function WorkspaceReportFieldsPage({ /> {(!shouldUseNarrowLayout || !hasVisibleReportField || isLoading) && getHeaderText()} {!shouldShowEmptyState && !isLoading && shouldUseNarrowLayout && getHeaderText()} - {filteredReportFieldsSections.length > 15 && ( + {filteredReportFields.length > 15 && ( )} {isLoading && ( @@ -298,7 +290,7 @@ function WorkspaceReportFieldsPage({ color={theme.spinner} /> )} - {shouldShowEmptyState && filteredReportFieldsSections.length === 0 && ( + {shouldShowEmptyState && filteredReportFields.length === 0 && ( item && updateSelectedReportFields(item)} sections={[ { - data: filteredReportFieldsSections, + data: filteredReportFields, isDisabled: !policy, }, ]} diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 53feeb6173c32..4e32a1165884d 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -1,5 +1,5 @@ import lodashSortBy from 'lodash/sortBy'; -import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -31,6 +31,7 @@ import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; +import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useThreeDotsAnchorPosition from '@hooks/useThreeDotsAnchorPosition'; @@ -98,8 +99,6 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { const {isOffline} = useNetwork({onReconnect: fetchTags}); - const [inputValue, setInputValue] = useState(''); - useEffect(() => { fetchTags(); // eslint-disable-next-line react-compiler/react-compiler @@ -210,18 +209,9 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { })); }, [isMultiLevelTags, policyTagLists, selectedTags, canSelectMultiple, translate, updateWorkspaceRequiresTag, updateWorkspaceTagEnabled]); - const [, startTransition] = useTransition(); - const [filteredTagList, setFilteredTagList] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery - ? tagList.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.value?.toLowerCase().includes(normalizedSearchQuery)) - : tagList; - setFilteredTagList(lodashSortBy(filtered, 'value', localeCompare) as TagListItem[]); - }); - }, [tagList, inputValue]); + const filterTag = useCallback((tag: TagListItem, searchInput: string) => !!tag.text?.toLowerCase().includes(searchInput) || !!tag.value?.toLowerCase().includes(searchInput), []); + const sortTags = useCallback((tags: TagListItem[]) => lodashSortBy(tags, 'value', localeCompare) as TagListItem[], []); + const [inputValue, setInputValue, filteredTagList] = useSearchResults(tagList, filterTag, sortTags); const filteredTagListKeyedByName = useMemo( () => diff --git a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx index 59c7a02739bab..97f41a75c046b 100644 --- a/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceViewTagsPage.tsx @@ -1,5 +1,5 @@ import {useIsFocused} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState, useTransition} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; @@ -21,6 +21,7 @@ import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; +import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {turnOffMobileSelectionMode} from '@libs/actions/MobileSelectionMode'; @@ -70,7 +71,6 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { const currentTagListName = useMemo(() => getTagListName(policyTags, route.params.orderWeight), [policyTags, route.params.orderWeight]); const currentPolicyTag = policyTags?.[currentTagListName]; const isQuickSettingsFlow = !!backTo; - const [inputValue, setInputValue] = useState(''); const fetchTags = useCallback(() => { openPolicyTagsPage(policyID); @@ -130,18 +130,9 @@ function WorkspaceViewTagsPage({route}: WorkspaceViewTagsProps) { [currentPolicyTag?.tags, selectedTags, canSelectMultiple, translate, updateWorkspaceTagEnabled], ); - const [, startTransition] = useTransition(); - const [filteredTagList, setFilteredTagList] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery - ? tagList.filter((item) => !!item.text?.toLowerCase().includes(normalizedSearchQuery) || !!item.value?.toLowerCase().includes(normalizedSearchQuery)) - : tagList; - setFilteredTagList(filtered.sort((tagA, tagB) => localeCompare(tagA.value, tagB.value))); - }); - }, [tagList, inputValue]); + const filterTag = useCallback((tag: TagListItem, searchInput: string) => !!tag.text?.toLowerCase().includes(searchInput) || !!tag.value?.toLowerCase().includes(searchInput), []); + const sortTags = useCallback((tags: TagListItem[]) => tags.sort((tagA, tagB) => localeCompare(tagA.value, tagB.value)), []); + const [inputValue, setInputValue, filteredTagList] = useSearchResults(tagList, filterTag, sortTags); const hasDependentTags = useMemo(() => hasDependentTagsPolicyUtils(policy, policyTags), [policy, policyTags]); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 2c5f79efcfe99..59de35ae5d5ef 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useMemo, useState, useTransition} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; @@ -24,6 +24,7 @@ import useMobileSelectionMode from '@hooks/useMobileSelectionMode'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchBackPress from '@hooks/useSearchBackPress'; +import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; @@ -83,7 +84,6 @@ function WorkspaceTaxesPage({ const enabledRatesCount = selectedTaxesIDs.filter((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled).length; const disabledRatesCount = selectedTaxesIDs.length - enabledRatesCount; - const [inputValue, setInputValue] = useState(''); const fetchTaxes = useCallback(() => { openPolicyTaxesPage(policyID); @@ -183,22 +183,19 @@ function WorkspaceTaxesPage({ }); }, [policy, textForDefault, selectedTaxesIDs, canSelectMultiple, translate, updateWorkspaceTaxEnabled]); - const [, startTransition] = useTransition(); - const [filteredTaxesList, setFilteredTaxesList] = useState([]); - - useEffect(() => { - startTransition(() => { - const normalizedSearchQuery = inputValue.trim().toLowerCase(); - const filtered = normalizedSearchQuery - ? taxesList.filter((tax) => { - const taxName = tax.text?.toLowerCase() ?? ''; - const taxAlternateText = tax.alternateText?.toLowerCase() ?? ''; - return taxName.includes(normalizedSearchQuery) || taxAlternateText.includes(normalizedSearchQuery); - }) - : taxesList; - setFilteredTaxesList(filtered.sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? ''))); + const filterTax = useCallback((tax: ListItem, searchInput: string) => { + const taxName = tax.text?.toLowerCase() ?? ''; + const taxAlternateText = tax.alternateText?.toLowerCase() ?? ''; + return taxName.includes(searchInput) || taxAlternateText.includes(searchInput); + }, []); + const sortTaxes = useCallback((taxes: ListItem[]) => { + return taxes.sort((a, b) => { + const aText = a.text ?? a.keyForList ?? ''; + const bText = b.text ?? b.keyForList ?? ''; + return aText.localeCompare(bText); }); - }, [inputValue, taxesList]); + }, []); + const [inputValue, setInputValue, filteredTaxesList] = useSearchResults(taxesList, filterTax, sortTaxes); const isLoading = !isOffline && taxesList === undefined; From a39f5c9f58a4cb3c66963753565ff60352ea1450 Mon Sep 17 00:00:00 2001 From: daledah Date: Mon, 5 May 2025 13:45:08 +0700 Subject: [PATCH 14/15] refactor: create const, fix delete error --- src/CONST.ts | 1 + src/hooks/useSearchResults.ts | 13 ++++++++++++- src/pages/workspace/WorkspaceMembersPage.tsx | 2 +- .../categories/WorkspaceCategoriesPage.tsx | 2 +- .../distanceRates/PolicyDistanceRatesPage.tsx | 4 ++-- .../WorkspaceExpensifyCardListPage.tsx | 2 +- .../workspace/perDiem/WorkspacePerDiemPage.tsx | 2 +- .../reportFields/ReportFieldsListValuesPage.tsx | 2 +- .../reportFields/WorkspaceReportFieldsPage.tsx | 4 ++-- src/pages/workspace/tags/WorkspaceTagsPage.tsx | 2 +- src/pages/workspace/tags/WorkspaceViewTagsPage.tsx | 2 +- src/pages/workspace/taxes/WorkspaceTaxesPage.tsx | 2 +- 12 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 861c767fa48e3..246928503d5fd 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -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%', diff --git a/src/hooks/useSearchResults.ts b/src/hooks/useSearchResults.ts index 5bd5d85cd100b..8798f2562abba 100644 --- a/src/hooks/useSearchResults.ts +++ b/src/hooks/useSearchResults.ts @@ -1,4 +1,6 @@ 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. @@ -9,14 +11,23 @@ function useSearchResults(data: TValue[], filterData: (datum: TValue, se 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 ? data.filter((item) => filterData(item, inputValue)) : data; + const filtered = normalizedSearchQuery.length ? data.filter((item) => filterData(item, normalizedSearchQuery)) : data; const sorted = sortData(filtered); setResult(sorted); }); }, [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; } diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index c5f7e5b043539..4bd342da6a07b 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -723,7 +723,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson <> {shouldUseNarrowLayout && {getHeaderButtons()}} {shouldUseNarrowLayout ? {getHeaderContent()} : getHeaderContent()} - {data.length > 15 && ( + {data.length > CONST.SEARCH_ITEM_LIMIT && ( {shouldUseNarrowLayout && {getHeaderButtons()}} {hasVisibleCategories && !isLoading && getHeaderText()} - {categoryList.length > 15 && ( + {categoryList.length > CONST.SEARCH_ITEM_LIMIT && ( rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + .filter(([, rate]) => rate.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredDistanceRatesList.some((item) => item.value === rate.customUnitRateID)) .map(([key]) => key), ); } @@ -376,7 +376,7 @@ function PolicyDistanceRatesPage({ {shouldUseNarrowLayout && {headerButtons}} {Object.values(customUnitRates).length > 0 && getHeaderText()} - {Object.values(customUnitRates).length > 15 && ( + {Object.values(customUnitRates).length > CONST.SEARCH_ITEM_LIMIT && ( - {allCards.length > 15 && ( + {allCards.length > CONST.SEARCH_ITEM_LIMIT && ( {shouldUseNarrowLayout && {getHeaderButtons()}} {(!hasVisibleSubRates || isLoading) && getHeaderText()} - {subRatesList.length > 15 && ( + {subRatesList.length > CONST.SEARCH_ITEM_LIMIT && ( {translate('workspace.reportFields.listInputSubtitle')} - {filteredListValues.length > 15 && ( + {filteredListValues.length > CONST.SEARCH_ITEM_LIMIT && ( reportField.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && filteredReportFields.find((item) => item.fieldID === reportField.fieldID), ); - setSelectedReportFields(selectedReportFields.length > 0 ? [] : Object.keys(availableReportFields)); + setSelectedReportFields(selectedReportFields.length > 0 ? [] : Object.values(availableReportFields).map((reportField) => getReportFieldKey(reportField.fieldID))); }; const navigateToReportFieldsSettings = (reportField: ReportFieldForList) => { @@ -282,7 +282,7 @@ function WorkspaceReportFieldsPage({ /> {(!shouldUseNarrowLayout || !hasVisibleReportField || isLoading) && getHeaderText()} {!shouldShowEmptyState && !isLoading && shouldUseNarrowLayout && getHeaderText()} - {filteredReportFields.length > 15 && ( + {reportFieldsSections.length > CONST.SEARCH_ITEM_LIMIT && ( {(!shouldUseNarrowLayout || !hasVisibleTags || isLoading) && getHeaderText()} - {tagList.length > 15 && ( + {tagList.length > CONST.SEARCH_ITEM_LIMIT && ( )} - {tagList.length > 15 && ( + {tagList.length > CONST.SEARCH_ITEM_LIMIT && ( {headerButtons}} {getHeaderText()} - {taxesList.length > 15 && ( + {taxesList.length > CONST.SEARCH_ITEM_LIMIT && ( Date: Wed, 7 May 2025 14:46:53 +0700 Subject: [PATCH 15/15] fix: test --- tests/unit/CardUtilsTest.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts index 0bffc32ab0f93..947b67a3570bd 100644 --- a/tests/unit/CardUtilsTest.ts +++ b/tests/unit/CardUtilsTest.ts @@ -10,6 +10,7 @@ import { getBankCardDetailsImage, getBankName, getCardFeedIcon, + getCardsByCardholderName, getCompanyFeeds, getCustomOrFormattedFeedName, getFeedType, @@ -923,7 +924,8 @@ describe('CardUtils', () => { it('should sort cards by cardholder name in ascending order', () => { const policyMembersAccountIDs = [1, 2, 3]; - const sortedCards = sortCardsByCardholderName(mockCards, mockPersonalDetails, policyMembersAccountIDs); + const cards = getCardsByCardholderName(mockCards, policyMembersAccountIDs); + const sortedCards = sortCardsByCardholderName(cards, mockPersonalDetails); expect(sortedCards).toHaveLength(3); expect(sortedCards.at(0)?.cardID).toBe(2); @@ -933,7 +935,8 @@ describe('CardUtils', () => { it('should filter out cards that are not associated with policy members', () => { const policyMembersAccountIDs = [1, 2]; // Exclude accountID 3 - const sortedCards = sortCardsByCardholderName(mockCards, mockPersonalDetails, policyMembersAccountIDs); + const cards = getCardsByCardholderName(mockCards, policyMembersAccountIDs); + const sortedCards = sortCardsByCardholderName(cards, mockPersonalDetails); expect(sortedCards).toHaveLength(2); expect(sortedCards.at(0)?.cardID).toBe(2); @@ -942,14 +945,16 @@ describe('CardUtils', () => { it('should handle undefined cardsList', () => { const policyMembersAccountIDs = [1, 2, 3]; - const sortedCards = sortCardsByCardholderName(undefined, mockPersonalDetails, policyMembersAccountIDs); + const cards = getCardsByCardholderName(undefined, policyMembersAccountIDs); + const sortedCards = sortCardsByCardholderName(cards, mockPersonalDetails); expect(sortedCards).toHaveLength(0); }); it('should handle undefined personalDetails', () => { const policyMembersAccountIDs = [1, 2, 3]; - const sortedCards = sortCardsByCardholderName(mockCards, undefined, policyMembersAccountIDs); + const cards = getCardsByCardholderName(mockCards, policyMembersAccountIDs); + const sortedCards = sortCardsByCardholderName(cards, undefined); expect(sortedCards).toHaveLength(3); // All cards should be sorted with default names @@ -986,7 +991,8 @@ describe('CardUtils', () => { }; const policyMembersAccountIDs = [1, 2]; - const sortedCards = sortCardsByCardholderName(cardsWithMissingAccountID, mockPersonalDetails, policyMembersAccountIDs); + const cards = getCardsByCardholderName(cardsWithMissingAccountID, policyMembersAccountIDs); + const sortedCards = sortCardsByCardholderName(cards, mockPersonalDetails); expect(sortedCards).toHaveLength(1); expect(sortedCards.at(0)?.cardID).toBe(1);