Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src/components/Search/SearchBulkActionsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type {BulkPaySelectionData, SearchQueryJSON} from './types';

import BulkDuplicateHandler from './BulkDuplicateHandler';
import BulkDuplicateReportHandler from './BulkDuplicateReportHandler';
import {useSearchSelectionContext} from './SearchContext';
import {useSearchResultsContext, useSearchSelectionContext} from './SearchContext';

type SearchBulkActionsButtonProps = {
queryJSON: SearchQueryJSON;
Expand All @@ -46,7 +46,8 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
// We need isSmallScreenWidth (not just shouldUseNarrowLayout) because DecisionModal requires it for correct modal type
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout();
const {selectedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const kycWallRef = useContext(KYCWallContext);
const {isAccountLocked} = useLockedAccountState();
const {showLockedAccountModal} = useLockedAccountActions();
Expand Down Expand Up @@ -122,7 +123,13 @@ function SearchBulkActionsButton({queryJSON}: SearchBulkActionsButtonProps) {
}, 0);
}, [selectedTransactions, selectedTransactionsKeys, isExpenseReportType, searchData]);

const selectionButtonText = areAllMatchingItemsSelected ? translate('search.exportAll.allMatchingItemsSelected') : translate('workspace.common.selected', {count: selectedItemsCount});
// While "select all matching" is on, the real count is the server-wide total minus the rows the user excluded.
const excludedTransactionsCount = Object.keys(excludedTransactions).length;
const allMatchingSelectedItemsCount = Math.max((currentSearchResults?.search?.count ?? selectedItemsCount) - excludedTransactionsCount, 0);
const selectionButtonText =
areAllMatchingItemsSelected && excludedTransactionsCount === 0
? translate('search.exportAll.allMatchingItemsSelected')
: translate('workspace.common.selected', {count: areAllMatchingItemsSelected ? allMatchingSelectedItemsCount : selectedItemsCount});

return (
<>
Expand Down
1 change: 1 addition & 0 deletions src/components/Search/SearchContextDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const defaultSearchResultsActions: SearchResultsActionsValue = {
const defaultSearchSelectionContext: SearchSelectionContextValue = {
currentSelectedTransactionReportID: undefined,
selectedTransactions: {},
excludedTransactions: {},
selectedTransactionIDs: [],
selectedReports: [],
shouldTurnOffSelectionMode: false,
Expand Down
14 changes: 11 additions & 3 deletions src/components/Search/SearchSelectionFooter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type SearchSelectionFooterProps = {
// Self-subscribing footer leaf. Owns the `selectedTransactions` read so a checkbox press re-renders only this
// footer — not SearchPage and the <Search> list it contains.
function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
const shouldAllowFooterTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true);
Expand All @@ -36,6 +36,12 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
const shouldUseClientTotal = !metadata?.count || (selectedTransactionsKeys.length > 0 && !areAllMatchingItemsSelected);
const selectedTransactionItems = Object.values(selectedTransactions);
const currency = metadata?.currency ?? selectedTransactionItems.at(0)?.groupCurrency ?? selectedTransactionItems.at(0)?.currency;

// In "select all matching" mode the footer reflects the server-wide metadata; subtract the rows the user excluded.
const excludedTransactionItems = Object.values(excludedTransactions);
const excludedTransactionsCount = excludedTransactionItems.length;
const excludedTotal = excludedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0);

const count = shouldUseClientTotal
? selectedTransactionsKeys.reduce((acc, key) => {
if (isGroupEntry(key)) {
Expand All @@ -48,8 +54,10 @@ function SearchSelectionFooter({searchResults}: SearchSelectionFooterProps) {
}
return acc + 1;
}, 0)
: metadata?.count;
const total = shouldUseClientTotal ? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0) : metadata?.total;
: (metadata?.count ?? 0) - excludedTransactionsCount;
const total = shouldUseClientTotal
? selectedTransactionItems.reduce((acc, transaction) => acc - (transaction.groupAmount ?? -Math.abs(transaction.amount)), 0)
: (metadata?.total ?? 0) - excludedTotal;

return (
<SearchPageFooter
Expand Down
50 changes: 44 additions & 6 deletions src/components/Search/SearchSelectionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type SearchSelectionProviderProps = {

type SelectionState = {
selectedTransactions: SelectedTransactions;
excludedTransactions: SelectedTransactions;
selectedTransactionIDs: string[];
selectedReports: SelectedReports[];
currentSelectedTransactionReportID: string | undefined;
Expand All @@ -23,6 +24,7 @@ type SelectionState = {

const defaultSelectionState: SelectionState = {
selectedTransactions: {},
excludedTransactions: {},
selectedTransactionIDs: [],
selectedReports: [],
currentSelectedTransactionReportID: undefined,
Expand Down Expand Up @@ -86,13 +88,39 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) {
return prevState;
}

// "Select all matching" path: a user row/group toggle while every matching item is selected should narrow
// the selection to "all matching EXCEPT this row", not collapse it to the loaded page. We keep the flag on
// and move the unchecked row(s) into `excludedTransactions` (re-checking a row removes it from the set).
if (prevState.areAllMatchingItemsSelected && options?.shouldUpdateMatchingExclusions) {
const excludedTransactions = {...prevState.excludedTransactions};
for (const key of Object.keys(prevState.selectedTransactions)) {
if (key in selectedTransactions) {
continue;
}
excludedTransactions[key] = prevState.selectedTransactions[key];
}
for (const key of Object.keys(selectedTransactions)) {
delete excludedTransactions[key];
}

return {
...prevState,
selectedTransactions,
excludedTransactions,
selectedReports: options?.data ? deriveSelectedReports(selectedTransactions, options.data) : prevState.selectedReports,
shouldTurnOffSelectionMode: false,
};
}

const totalSelectableItemsCount = options?.totalSelectableItemsCount;
const areAllMatchingItemsSelected =
totalSelectableItemsCount && totalSelectableItemsCount !== Object.keys(selectedTransactions).length ? false : prevState.areAllMatchingItemsSelected;

return {
...prevState,
selectedTransactions,
// Exclusions only make sense while select-all is on; drop them as soon as the flag clears.
excludedTransactions: areAllMatchingItemsSelected ? prevState.excludedTransactions : {},
areAllMatchingItemsSelected,
selectedReports: options?.data ? deriveSelectedReports(selectedTransactions, options.data) : prevState.selectedReports,
shouldTurnOffSelectionMode: false,
Expand Down Expand Up @@ -132,6 +160,8 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) {
return {
...prevState,
areAllMatchingItemsSelected: shouldSelectAll,
// Start each "select all matching" session (and each exit) with a clean exclusion set.
excludedTransactions: {},
};
});
};
Expand All @@ -154,6 +184,7 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) {
...prevState,
shouldTurnOffSelectionMode,
selectedTransactions: {},
excludedTransactions: {},
selectedReports: [],
areAllMatchingItemsSelected: false,
};
Expand Down Expand Up @@ -191,7 +222,10 @@ function SearchSelectionProvider({children}: SearchSelectionProviderProps) {
});
};

const hasSelectedTransactions = selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected);
// "Select all matching" is always a non-empty selection (all matching minus a few exclusions), even after the
// user unchecks every currently-visible row — so it must count as having a selection on its own.
const hasSelectedTransactions =
selectionState.areAllMatchingItemsSelected || selectionState.selectedTransactionIDs.length > 0 || Object.values(selectionState.selectedTransactions).some((t) => t.isSelected);

const selectionValue: SearchSelectionContextValue = {
...selectionState,
Expand Down Expand Up @@ -242,18 +276,22 @@ function useSyncSelectedReports(data: SearchData) {

/** Narrow per-row selection read: whether the row for `keyForList` is selected (or covered by select-all). */
function useRowSelection(keyForList: string | undefined): {isSelected: boolean} {
const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
if (!keyForList) {
return {isSelected: false};
}
return {isSelected: areAllMatchingItemsSelected || !!selectedTransactions[keyForList]?.isSelected};
// While "select all matching" is on, a row is selected unless the user explicitly excluded it.
return {isSelected: (areAllMatchingItemsSelected && !excludedTransactions[keyForList]) || !!selectedTransactions[keyForList]?.isSelected};
}

/** Aggregate count of currently-selected transactions, for the selection top bar. */
function useSelectionCounts(): {selected: number} {
const {selectedTransactions} = useSearchSelectionContext();
const selected = Object.values(selectedTransactions).filter((value) => value?.isSelected).length;
return {selected};
const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const visibleSelected = Object.values(selectedTransactions).filter((value) => value?.isSelected).length;
// In "select all matching" mode the true total lives in the search metadata (see SearchBulkActionsButton /
// SearchSelectionFooter). Report a positive count here so the bulk-actions bar stays visible even after the user
// excludes every currently-visible row.
return {selected: areAllMatchingItemsSelected ? Math.max(visibleSelected, 1) : visibleSelected};
}

export {SearchSelectionProvider, useSyncSelectedReports, useRowSelection, useSelectionCounts};
15 changes: 9 additions & 6 deletions src/components/Search/SearchWriteActionsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function useReconcileSelectionWithData({
reportNameValuePairs,
outstandingReportsByPolicyID,
}: ReconcileSelectionParams) {
const {selectedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {applySelection} = useSearchSelectionActions();

useEffect(() => {
Expand All @@ -150,7 +150,7 @@ function useReconcileSelectionWithData({
if (transactionGroup.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
continue;
}
if (reportKey && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) {
if (reportKey && !excludedTransactions[reportKey] && (reportKey in selectedTransactions || areAllMatchingItemsSelected)) {
const [, emptyReportSelection] = mapEmptyReportToSelectedEntry(transactionGroup);
newTransactionList[reportKey] = {
...emptyReportSelection,
Expand All @@ -173,9 +173,11 @@ function useReconcileSelectionWithData({
for (const transactionItem of transactionGroup.transactions) {
const listKey = transactionItem.keyForList ?? transactionItem.transactionID;
const isSelected = listKey in selectedTransactions || transactionItem.transactionID in selectedTransactions;
// A row the user unchecked while select-all is on must not be re-added by a later data reconcile.
const isExcluded = !!excludedTransactions[listKey] || !!excludedTransactions[transactionItem.transactionID];

// Include transaction if: already individually selected, part of select-all, or group-level propagation (expense report / empty group expanded)
const shouldInclude = isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows;
const shouldInclude = !isExcluded && (isSelected || areAllMatchingItemsSelected || propagateSelectionToAllRows);
if (!shouldInclude) {
continue;
}
Expand Down Expand Up @@ -218,7 +220,8 @@ function useReconcileSelectionWithData({
continue;
}
const listKey = transactionItem.keyForList ?? transactionItem.transactionID;
if (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected) {
const isExcluded = !!excludedTransactions[listKey] || !!excludedTransactions[transactionItem.transactionID];
if (isExcluded || (!(listKey in selectedTransactions) && !(transactionItem.transactionID in selectedTransactions) && !areAllMatchingItemsSelected)) {
continue;
}

Expand Down Expand Up @@ -392,7 +395,7 @@ function SearchWriteActionsProvider({

return updatedTransactions;
},
{totalSelectableItemsCount},
{totalSelectableItemsCount, shouldUpdateMatchingExclusions: true},
);
return;
}
Expand Down Expand Up @@ -456,7 +459,7 @@ function SearchWriteActionsProvider({
),
};
},
{totalSelectableItemsCount},
{totalSelectableItemsCount, shouldUpdateMatchingExclusions: true},
);
};

Expand Down
12 changes: 11 additions & 1 deletion src/components/Search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@ type SearchResultsActionsValue = {
type SearchSelectionContextValue = {
currentSelectedTransactionReportID: string | undefined;
selectedTransactions: SelectedTransactions;
/**
* Rows the user explicitly unchecked while "select all matching" was active. Represents the "all matching
* EXCEPT these" state that the single `areAllMatchingItemsSelected` flag can't express on its own. Only ever
* populated while `areAllMatchingItemsSelected` is true; cleared whenever select-all is toggled or the
* selection is cleared.
*/
excludedTransactions: SelectedTransactions;
selectedTransactionIDs: string[];
selectedReports: SelectedReports[];
shouldTurnOffSelectionMode: boolean;
Expand All @@ -235,7 +242,10 @@ type SearchSelectionActionsValue = {
* thus re-rendering on — selection state. Passing `data` derives `selectedReports` in the same commit; passing
* `totalSelectableItemsCount` unchecks "select all matching" when the new selection no longer covers every item.
*/
applySelection: (updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions, options?: {data?: SearchData; totalSelectableItemsCount?: number}) => void;
applySelection: (
updater: (previousSelectedTransactions: SelectedTransactions) => SelectedTransactions,
options?: {data?: SearchData; totalSelectableItemsCount?: number; shouldUpdateMatchingExclusions?: boolean},
) => void;
setSelectedReports: (reports: SelectedReports[]) => void;
setCurrentSelectedTransactionReportID: (reportID: string | undefined) => void;
/** If you want to clear `selectedTransactionIDs`, pass `true` as the first argument */
Expand Down
8 changes: 7 additions & 1 deletion src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const {isProduction} = useEnvironment();
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
const {selectedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {selectedTransactions, excludedTransactions, selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const {currentSearchKey} = useSearchQueryContext();
const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions();
Expand Down Expand Up @@ -632,6 +632,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
jsonQuery: serializedQuery,
reportIDList: [],
transactionIDList: [],
// "All matching except these": the backend re-runs the query and skips the excluded IDs.
excludedTransactionIDList: Object.keys(excludedTransactions),
policyID,
exportName,
},
Expand All @@ -657,6 +659,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
[
selectedReports,
selectedTransactions,
excludedTransactions,
isOffline,
areAllMatchingItemsSelected,
currentSearchResults?.data,
Expand Down Expand Up @@ -735,6 +738,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
jsonQuery: exportParameters.jsonQuery,
reportIDList,
transactionIDList: selectedTransactionsKeys,
// "All matching except these": the backend re-runs the query and skips the excluded IDs.
excludedTransactionIDList: Object.keys(excludedTransactions),
isBasicExport: exportParameters.isBasicExport,
exportColumnLabels: exportParameters.exportColumnLabels,
exportName,
Expand Down Expand Up @@ -778,6 +783,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
selectedReportIDs,
selectedTransactionReportIDs,
selectedTransactions,
excludedTransactions,
selectedTransactionsKeys,
translate,
clearSelectedTransactions,
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/parameters/ExportSearchItemsToCSVParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ type ExportSearchItemsToCSVParams = {
jsonQuery: SearchQueryString;
reportIDList: string[];
transactionIDList: string[];
/** When "select all matching" is active, the IDs the user explicitly excluded so the backend skips them. */
excludedTransactionIDList?: string[];
isBasicExport: boolean;
exportColumnLabels: string;
exportName: string;
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/parameters/ExportSearchWithTemplateParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ type ExportSearchWithTemplateParams = {
jsonQuery: SearchQueryString;
reportIDList: string[];
transactionIDList: string[];
/** When "select all matching" is active, the IDs the user explicitly excluded so the backend skips them. */
excludedTransactionIDList?: string[];
policyID: string | undefined;
exportName: string;
};
Expand Down
Loading
Loading