Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
90880c4
Support 'Select all matching' when moving expenses to a report
MelvinBot Aug 12, 2026
2e2ff61
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Aug 19, 2026
c499c08
Recheck connectivity before submitting an all-matching query move
MelvinBot Aug 19, 2026
a6de496
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Aug 24, 2026
f38ed17
Address review comments: propagate all-matching query through workspa…
MelvinBot Aug 24, 2026
ee1098c
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Aug 26, 2026
4377d98
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Aug 31, 2026
dd9acb7
Surface backend rejections generically per API command
MelvinBot Aug 31, 2026
c1618dc
Revert "Surface backend rejections generically per API command"
MelvinBot Sep 2, 2026
3aa6a19
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Sep 2, 2026
0adb19e
Merge main into claude-selectAllMatchingMoveExpenses
MelvinBot Sep 7, 2026
a23a3a4
Merge main and apply the normal optimistic update on the all-matching…
MelvinBot Sep 7, 2026
f8ca62f
Merge main into claude-selectAllMatchingMoveExpenses
MelvinBot Sep 8, 2026
c4dbba0
Simplify the code comments added by this PR
MelvinBot Sep 8, 2026
2549256
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Sep 10, 2026
f08762f
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Sep 15, 2026
7637c11
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Sep 17, 2026
3f1a462
Merge remote-tracking branch 'origin/main' into claude-selectAllMatch…
MelvinBot Sep 18, 2026
8f2b1cc
Pass isVendorMatchingBetaEnabled in the all-matching move tests after…
MelvinBot Sep 18, 2026
67bee6d
Simplify the code comments added by the all-matching move change
MelvinBot Sep 22, 2026
dc3215e
Merge branch 'main' into claude-selectAllMatchingMoveExpenses
MelvinBot Sep 24, 2026
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
2 changes: 2 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7287,6 +7287,8 @@ const CONST = {
TAG_FILTER_PAGE_SIZE: 200,
EXITING_ANIMATION_DURATION: 200,
ME: 'me',
// Null byte can't appear in a query, so it's safe to join query parts with it
QUERY_PARAMS_SEPARATOR: '\x00',
/** How far the cursor may wander from where it last counted as moving over the advanced filter list and still count as resting */
HOVER_INTENT_REST_RADIUS_PX: 8,
DATA_TYPES: {
Expand Down
27 changes: 20 additions & 7 deletions src/components/Search/SearchQueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,21 +26,34 @@ type SearchQueryProviderProps = {
children: React.ReactNode;
};

function selectSearchQueryParam(state: NavigationState | undefined) {
return getSearchRootParamsFromRootState(state)?.q;
/** Joins `q` and `rawQuery` so they can't drift apart. Restoring only `q` would drop `rawFilterList` while keeping the same hash. */
function selectSearchQueryParams(state: NavigationState | undefined) {
const searchRootParams = getSearchRootParamsFromRootState(state);
if (!searchRootParams) {
return undefined;
}
return `${searchRootParams.q}${CONST.SEARCH.QUERY_PARAMS_SEPARATOR}${searchRootParams.rawQuery ?? ''}`;
}

function selectSearchRawQueryParam(state: NavigationState | undefined) {
return getSearchRootParamsFromRootState(state)?.rawQuery;
function splitSearchQueryParams(queryParams: string | undefined) {
if (queryParams === undefined) {
return {query: undefined, rawQuery: undefined};
}
const separatorIndex = queryParams.indexOf(CONST.SEARCH.QUERY_PARAMS_SEPARATOR);
if (separatorIndex === -1) {
return {query: queryParams, rawQuery: undefined};
}
const rawQuery = queryParams.slice(separatorIndex + CONST.SEARCH.QUERY_PARAMS_SEPARATOR.length);
return {query: queryParams.slice(0, separatorIndex), rawQuery: rawQuery || undefined};
}

function SearchQueryProvider({children}: SearchQueryProviderProps) {
const navigation = useNavigation();
// Extract only the primitive values we need from the resolved Search root route to avoid
// re-renders from new object references returned by getSearchRootParamsFromRootState.
const queryParam = useRootNavigationState((state) => selectSearchQueryParam(state ?? navigation.getState()));
const rawQueryParam = useRootNavigationState((state) => selectSearchRawQueryParam(state ?? navigation.getState()));
const definedQueryParam = usePreviousDefined(queryParam) ?? buildSearchQueryString();
const queryParams = useRootNavigationState((state) => selectSearchQueryParams(state ?? navigation.getState()));
const {query: queryParam, rawQuery: rawQueryParam} = splitSearchQueryParams(usePreviousDefined(queryParams));
const definedQueryParam = queryParam ?? buildSearchQueryString();
const currentSearchQueryJSON = buildSearchQueryJSON(definedQueryParam, rawQueryParam);
const shouldLoadCategoryData = currentSearchQueryJSON?.flatFilters.some((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) ?? false;
useLoadSearchCategoryData({shouldLoad: shouldLoadCategoryData});
Expand Down
130 changes: 78 additions & 52 deletions src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ import {doesPersonalDetailExistSelector} from '@src/selectors/PersonalDetails';
import type {BillingGraceEndPeriod, ExportTemplate, Policy, Report, ReportAction, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx';
import type {SearchResultDataType} from '@src/types/onyx/SearchResults';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import {getEmptyObject, isEmptyObject} from '@src/types/utils/EmptyObject';

import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
Expand Down Expand Up @@ -1929,6 +1929,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
});
}, [selectedReports, currentSearchResults?.data, isTrackIntentUser, policies, selectedTransactions, rules]);

// Some matches haven't been loaded yet, so the client can't check whether they are movable
const hasUnloadedMatchingExpenses = !!currentSearchResults?.search?.hasMoreResults;
const isUnreportedOnlyQuery = useMemo(() => {
const statusFilter = queryJSON ? getFilterFromQuery(queryJSON, CONST.SEARCH.SYNTAX_FILTER_KEYS.STATUS) : undefined;
return !statusFilter?.isNegated && !!statusFilter?.value?.length && statusFilter.value.every((status) => status === CONST.SEARCH.STATUS.EXPENSE.UNREPORTED);
}, [queryJSON]);

const {headerButtonsOptions, dropdownButtonsOptions} = useMemo(() => {
if ((selectedTransactionsKeys.length === 0 && !(isExpenseType && areAllMatchingItemsSelected)) || !hash) {
const noOptions = CONST.EMPTY_ARRAY as unknown as Array<DropdownOption<SearchHeaderOptionValue>>;
Expand Down Expand Up @@ -2330,6 +2337,57 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
dropdownButtonsOptions: openExportOptionsDirectlyIfSoleAction(builtOptions),
});

// These checks only see the loaded page. The backend enforces the same rules on the full set.
const moveOwnerAccountIDs = new Set<number>();
let moveHasUnknownOwner = false;
for (const id of selectedTransactionsKeys) {
const transactionEntry = selectedTransactions[id];
if (!transactionEntry) {
continue;
}
const ownerAccountID =
transactionEntry.ownerAccountID ??
getReportOrDraftReport(transactionEntry.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionEntry.reportID}`])?.ownerAccountID;
if (typeof ownerAccountID === 'number') {
moveOwnerAccountIDs.add(ownerAccountID);
} else {
moveHasUnknownOwner = true;
}
}
const moveHasMultipleOwners = moveOwnerAccountIDs.size > 1 || (moveHasUnknownOwner && (moveOwnerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1));

// For selections across submitters, offer only Auto report when every expense has a resolved owner,
// is on a managed card, and does not depend on the destination workspace. Otherwise hide the flow.
const canAutoReportAcrossSubmitters =
moveOwnerAccountIDs.size > 1 &&
!moveHasUnknownOwner &&
selectedTransactionsKeys.every((id) => {
const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`];
if (!transaction || !isManagedCardTransaction(transaction)) {
return false;
}
return !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction));
});

// `every` returns true for an empty selection, so check the length too. The destination page has nothing to move.
const canAllTransactionsBeMoved = selectedTransactionsKeys.length > 0 && selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport);
const canMoveExpenses = canAllTransactionsBeMoved && !isExpenseReportType && (!moveHasMultipleOwners || canAutoReportAcrossSubmitters);

const moveExpensesOption: DropdownOption<SearchHeaderOptionValue> = {
text: translate('iou.moveExpenses'),
icon: expensifyIcons.DocumentMerge,
value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_REPORT,
shouldCloseModalOnSelect: true,
onSelected: () => {
// A queued all-matching move would send a stale query, so ask the user to reconnect like export does
if (areAllMatchingItemsSelected && isOffline) {
setIsOfflineModalVisible(true);
return;
}
Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.getRoute());
},
};

const {shouldEnableBulkPayOption} = getPayOption(selectedReports, selectedTransactions, lastPaymentMethods, selectedReportIDs, personalPolicyID);
const hasLoadedPayableReport = payableSelectedReports.length > 0 || selectedReports.length === 0;
const shouldShowPayOption = areAllMatchingItemsSelected
Expand Down Expand Up @@ -2389,11 +2447,23 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
};

if (areAllMatchingItemsSelected) {
const selectAllOptions = shouldShowPayOption ? [payButtonOption, exportButtonOption] : [exportButtonOption];
// Offer an all-matching move only when no rows are excluded and unloaded matches are unreported.
// The backend rejects the entire move if any matching expense is invalid
const isAllMatchingSelectionMovable = isEmptyObject(excludedTransactions) && (!hasUnloadedMatchingExpenses || isUnreportedOnlyQuery);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Do not assume every unloaded unreported expense is movable

For a status:unreported search spanning multiple pages, this bypasses hasUnloadedMatchingExpenses after checking canChangeReport only on the loaded rows. Unreported expenses are not universally movable: canUnreportedBeMoved() rejects per-diem transactions when their policy or rates are unavailable, so an invalid expense on a later page makes the backend reject the entire query move even though this option is offered. Do not treat the status filter alone as proof that all unseen matches are eligible.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — this is a real edge case, but I didn't change the behavior here because the honest fix is a product tradeoff that belongs to the PR author rather than a mechanical change, and I don't want to silently reverse the deliberate || isUnreportedOnlyQuery bypass.

The core constraint: the client can't enumerate the unloaded matches, so it can't prove every unseen unreported expense passes canUnreportedBeMoved() (per-diem with unavailable policy/rates being the counter-example you flagged). The backend is the authoritative enforcer either way. Two options:

  1. Keep the bypass (current), accept the tradeoff. Rare per-diem-on-a-later-page cases make the backend reject the whole move. This should be paired with surfacing that failure to the user (today the all-matching failure path only clears pendingFields.reportID — same silent behavior as the existing explicit-list path, so it isn't a regression, but it's worth fixing).
  2. Drop the bypass — require !hasUnloadedMatchingExpenses. The move option then only appears when the client has loaded every match, so canAllTransactionsBeMoved fully covers the set. Cost: all-matching move isn't offered for unreported searches with more than one page of results.

I'd lean toward (2) if guaranteed correctness matters more than covering the >1-page unreported case. ZhenjaHorbach — which tradeoff do you want? Reply and I'll implement it.


const allMatchingOptions: Array<DropdownOption<SearchHeaderOptionValue>> = [];
if (shouldShowPayOption) {
allMatchingOptions.push(payButtonOption);
}
allMatchingOptions.push(exportButtonOption);
if (isExpenseReportSearch) {
selectAllOptions.push(downloadPDFOption);
allMatchingOptions.push(downloadPDFOption);
}
return buildResult(selectAllOptions);
if (canMoveExpenses && isAllMatchingSelectionMovable) {
allMatchingOptions.push(moveExpensesOption);
}

return buildResult(allMatchingOptions);
}

if (allSelectedAreDeleted) {
Expand Down Expand Up @@ -2889,54 +2959,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
}
}

const ownerAccountIDs = new Set<number>();
let hasUnknownOwner = false;
for (const id of selectedTransactionsKeys) {
const transactionEntry = selectedTransactions[id];
if (!transactionEntry) {
continue;
}
const ownerAccountID =
transactionEntry.ownerAccountID ??
getReportOrDraftReport(transactionEntry.reportID, undefined, undefined, undefined, allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionEntry.reportID}`])?.ownerAccountID;
if (typeof ownerAccountID === 'number') {
ownerAccountIDs.add(ownerAccountID);
} else {
hasUnknownOwner = true;
}
}
const hasMultipleOwners = ownerAccountIDs.size > 1 || (hasUnknownOwner && (ownerAccountIDs.size > 0 || selectedTransactionsKeys.length > 1));

const canAllTransactionsBeMoved = selectedTransactionsKeys.every((id) => selectedTransactions[id].canChangeReport);

// Across submitters the only destination the App can offer is "Auto report". Every other mixed-owner selection
// stays hidden as before, so there is no entry into a screen that could only offer one submitter's reports to
// everybody else's expenses. Requirements:
// - every owner resolved, or the count below cannot tell one cardholder's bulk selection from a mixed one
// - every expense on a managed card, because the backend resolves each destination through the card; one
// expense without a card fails the whole request with "404 Card not found"
// - nothing whose validity depends on the destination workspace, which the backend picks: per diem rates and
// the map/GPS rules on manual and odometer distance can only be checked against a known workspace
// An expense we cannot read fails all three, so it withholds the flow rather than risking a rejected move.
const canAutoReportAcrossSubmitters =
ownerAccountIDs.size > 1 &&
!hasUnknownOwner &&
selectedTransactionsKeys.every((id) => {
const transaction = selectedTransactions[id]?.transaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`];
if (!transaction || !isManagedCardTransaction(transaction)) {
return false;
}
return !(isPerDiemRequest(transaction) || isManualDistanceRequest(transaction) || isOdometerDistanceRequest(transaction));
});

if (canAllTransactionsBeMoved && !isExpenseReportType && (!hasMultipleOwners || canAutoReportAcrossSubmitters)) {
options.push({
text: translate('iou.moveExpenses'),
icon: expensifyIcons.DocumentMerge,
value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_REPORT,
shouldCloseModalOnSelect: true,
onSelected: () => Navigation.navigate(ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.getRoute()),
});
if (canMoveExpenses) {
options.push(moveExpensesOption);
}

const firstTransactionKey = selectedTransactionsKeys.at(0);
Expand Down Expand Up @@ -3125,6 +3149,8 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
noReportsShouldMarkAsDone,
currentUserPersonalDetails.accountID,
delegateAccountID,
hasUnloadedMatchingExpenses,
isUnreportedOnlyQuery,
currentSearchQueryJSON,
currentSearchResults?.search?.isLoading,
shouldCalculateTotalsOnRefresh,
Expand Down
2 changes: 2 additions & 0 deletions src/libs/API/parameters/ChangeTransactionsReportParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ type ChangeTransactionsReportParams = {
reportID: string;
transactionIDToReportActionAndThreadData: string; // A map of transactionID to TransactionThreadInfo
transactionIDToUpdatedCustomUnitRateID?: string; // A JSON map of transactionID to the new customUnitRateID (for distance expenses moving to a workspace with an invalid rate)
jsonQuery?: string;
hash?: number;
};

export type {ChangeTransactionsReportParams, TransactionThreadInfo};
61 changes: 59 additions & 2 deletions src/libs/actions/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import {toLocaleDigit} from '@libs/LocaleDigitUtils';
import {translateLocal} from '@libs/Localize';
import Log from '@libs/Log';
import {buildOptimisticNextStep} from '@libs/NextStepUtils';
import * as NumberUtils from '@libs/NumberUtils';
import {rand64, roundToTwoDecimalPlaces} from '@libs/NumberUtils';
Expand Down Expand Up @@ -882,6 +883,8 @@ type ChangeTransactionsReportProps = {
getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'];
getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'];
isVendorMatchingBetaEnabled: boolean | undefined;
jsonQuery?: string;
hash?: number;
};

function getChangeTransactionsReportOnyxData({
Expand Down Expand Up @@ -2076,6 +2079,60 @@ function getChangeTransactionsReportOnyxData({
}

function changeTransactionsReport(props: ChangeTransactionsReportProps) {
const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID;

// Without a hash the move falls back to the explicit list and only the loaded page moves, so surface that
if (props.jsonQuery && props.hash === undefined) {
Log.warn('changeTransactionsReport: received an all-matching jsonQuery without a hash; falling back to the explicit transaction list, which only moves the loaded transactions.');
}

if (props.jsonQuery && props.hash !== undefined) {
// The backend moves the full query result while the client only has loaded transactions.
// Apply normal optimistic updates to loaded transactions, then send the request even if none are loaded.
const loadedTransactionsOnyxData = getChangeTransactionsReportOnyxData(props);

const optimisticData = [...(loadedTransactionsOnyxData?.optimisticData ?? [])];
const successData = [...(loadedTransactionsOnyxData?.successData ?? [])];
const failureData = [...(loadedTransactionsOnyxData?.failureData ?? [])];

if (props.newReport) {
// Unloaded expenses are still moving server-side, so the destination stays pending for the whole request
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${props.newReport.reportID}`,
value: {pendingFields: {reportID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}},
});
successData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${props.newReport.reportID}`,
value: {pendingFields: {reportID: null}},
});
failureData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${props.newReport.reportID}`,
value: {pendingFields: {reportID: null}},
});
}

const transactionIDToUpdatedCustomUnitRateID = loadedTransactionsOnyxData?.transactionIDToUpdatedCustomUnitRateID ?? {};

const queryParameters: ChangeTransactionsReportParams = {
// Stays empty so the backend moves every matching expense from the query, not just the loaded page
transactionList: '',
reportID,
// Send the optimistic action and thread IDs so the backend reuses them instead of adding a second moved message
transactionIDToReportActionAndThreadData: JSON.stringify(loadedTransactionsOnyxData?.transactionIDToReportActionAndThreadData ?? {}),
...(Object.keys(transactionIDToUpdatedCustomUnitRateID).length > 0 && {
transactionIDToUpdatedCustomUnitRateID: JSON.stringify(transactionIDToUpdatedCustomUnitRateID),
}),
jsonQuery: props.jsonQuery,
hash: props.hash,
};

API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, queryParameters, {optimisticData, successData, failureData});
return;
}

const changeTransactionsReportOnyxData = getChangeTransactionsReportOnyxData(props);
if (!changeTransactionsReportOnyxData) {
return;
Expand All @@ -2088,8 +2145,6 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) {
return;
}

const reportID = props.newReport?.reportID ?? CONST.REPORT.UNREPORTED_REPORT_ID;

const parameters: ChangeTransactionsReportParams = {
transactionList: movedTransactionIDs.join(','),
reportID,
Expand All @@ -2099,6 +2154,8 @@ function changeTransactionsReport(props: ChangeTransactionsReportProps) {
}),
};

// The all-matching path above already returned after its own API.write, so only one of the two writes ever runs
// eslint-disable-next-line rulesdir/no-multiple-api-calls

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

❌ CONSISTENCY-5 (docs)

This eslint-disable-next-line rulesdir/no-multiple-api-calls has no accompanying comment explaining why the rule is being suppressed. eslint-disable comments hide the violation from the seatbelt baseline permanently, so a justification is required for reviewers and future maintainers to understand the intent.

Add a justification comment above (or after) the disable, e.g.:

// This second API.write is intentional: the query-based all-matching path above already returned, so this branch only runs for the explicit-transaction move.
// eslint-disable-next-line rulesdir/no-multiple-api-calls
API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters, {

If this is a temporary suppression, prefer widening the baseline via SEATBELT_INCREASE instead so the violation stays visible.


Reviewed at: a6de496 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in f38ed17. Added a justification comment above the eslint-disable explaining that the query-based all-matching path above already called API.write and returned, so this branch only runs for the explicit-transaction move — the two writes are mutually exclusive and never fire within the same invocation.

See Transaction.ts:2069.

API.write(WRITE_COMMANDS.CHANGE_TRANSACTIONS_REPORT, parameters, {
optimisticData,
successData,
Expand Down
Loading
Loading