Skip to content
Open
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
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5358,6 +5358,7 @@ const CONST = {
TAX_RATE: 'taxRate',
TAX_AMOUNT: 'taxAmount',
REIMBURSABLE: 'reimbursable',
ATTENDEES: 'attendees',
BILLABLE: 'billable',
REPORT: 'report',
},
Expand Down
1 change: 1 addition & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1561,6 +1561,7 @@ const ROUTES = {
},
SEARCH_EDIT_MULTIPLE_BILLABLE_RHP: 'search/edit-multiple/billable',
SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP: 'search/edit-multiple/reimbursable',
SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP: 'search/edit-multiple/attendees',
SEARCH_EDIT_MULTIPLE_TAX_RHP: 'search/edit-multiple/tax',
MOVE_TRANSACTIONS_SEARCH_RHP: {
route: 'search/move-transactions/search/:backTo?',
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const SCREENS = {
EDIT_MULTIPLE_TAG_RHP: 'Search_Edit_Multiple_Tag_RHP',
EDIT_MULTIPLE_BILLABLE_RHP: 'Search_Edit_Multiple_Billable_RHP',
EDIT_MULTIPLE_REIMBURSABLE_RHP: 'Search_Edit_Multiple_Reimbursable_RHP',
EDIT_MULTIPLE_ATTENDEES_RHP: 'Search_Edit_Multiple_Attendees_RHP',
EDIT_MULTIPLE_TAX_RHP: 'Search_Edit_Multiple_Tax_RHP',
TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_Search',
CHANGE_APPROVER: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,7 @@ const SearchReportActionsModalStackNavigator = createModalStackNavigator<SearchR
[SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage').default,
[SCREENS.SEARCH.EDIT_MULTIPLE_REIMBURSABLE_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage').default,
[SCREENS.SEARCH.EDIT_MULTIPLE_TAX_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage').default,
[SCREENS.SEARCH.EDIT_MULTIPLE_ATTENDEES_RHP]: () => require<ReactComponentModule>('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage').default,
});

const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator({
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1960,6 +1960,7 @@ const config: LinkingOptions<RootNavigatorParamList>['config'] = {
[SCREENS.SEARCH.EDIT_MULTIPLE_TAG_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP.route,
[SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP,
[SCREENS.SEARCH.EDIT_MULTIPLE_REIMBURSABLE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP,
[SCREENS.SEARCH.EDIT_MULTIPLE_ATTENDEES_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP,
[SCREENS.SEARCH.EDIT_MULTIPLE_TAX_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP,
[SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: {
path: ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.route,
Expand Down
1 change: 1 addition & 0 deletions src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5162,6 +5162,7 @@ function canEditMultipleTransactions(
CONST.EDIT_REQUEST_FIELD.DATE,
CONST.EDIT_REQUEST_FIELD.BILLABLE,
CONST.EDIT_REQUEST_FIELD.REIMBURSABLE,
CONST.EDIT_REQUEST_FIELD.ATTENDEES,
CONST.EDIT_REQUEST_FIELD.TAX_RATE,
];

Expand Down
174 changes: 138 additions & 36 deletions src/libs/actions/IOU/BulkEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
isDistanceRequest,
isOnHold,
isSplitChildTransaction,
shouldShowAttendees,
} from '@libs/TransactionUtils';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';

Expand All @@ -46,10 +47,46 @@ import type {TransactionChanges} from '@src/types/onyx/Transaction';
import type {NullishDeep, OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';

import {deepEqual} from 'fast-equals';
// lodashUnionBy de-dupes recent attendees by email/displayName in one pass; no lodash-free equivalent is used here
// eslint-disable-next-line you-dont-need-lodash-underscore/union-by
Comment thread
samranahm marked this conversation as resolved.
import lodashUnionBy from 'lodash/unionBy';
import Onyx from 'react-native-onyx';

import {getRecentAttendees} from '.';
import {getUpdatedMoneyRequestReportData} from './MoneyRequestBuilder';

type BulkEditWriteOnyxData = {
optimisticData: Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.SNAPSHOT
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
| typeof ONYXKEYS.NVP_RECENT_ATTENDEES
>
>;
successData: Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.SNAPSHOT
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
>
>;
failureData: Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.SNAPSHOT
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
>
>;
};

function removeUnchangedBulkEditFields(
transactionChanges: TransactionChanges,
transaction: OnyxTypes.Transaction,
Expand All @@ -74,7 +111,8 @@ function removeUnchangedBulkEditFields(
const nextValue = transactionChanges[field];
const currentValue = currentDetails[field as keyof TransactionDetails];

if (nextValue !== currentValue) {
const hasChanged = field === CONST.EDIT_REQUEST_FIELD.ATTENDEES ? !deepEqual(nextValue, currentValue) : nextValue !== currentValue;
if (hasChanged) {
filteredChanges = {
...filteredChanges,
[field]: nextValue,
Expand Down Expand Up @@ -106,6 +144,29 @@ type UpdateMultipleMoneyRequestsParams = {
getCurrencySymbol: CurrencyListActionsContextType['getCurrencySymbol'];
};

function writeBulkEditMoneyRequest(
params: {
transactionID: string;
reportActionID: string;
updates: string;
},
onyxData?: BulkEditWriteOnyxData,
) {
API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, onyxData);
}

function writeBulkEditMoneyRequestAttendees(
params: {
transactionID: string;
attendees: string;
reportActionID?: string;
reportID?: string;
},
onyxData?: BulkEditWriteOnyxData,
) {
API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES, params, onyxData);
}

function updateMultipleMoneyRequests({
transactionIDs,
changes,
Expand Down Expand Up @@ -257,6 +318,9 @@ function updateMultipleMoneyRequests({
if (changes.reimbursable !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.REIMBURSABLE)) {
transactionChanges.reimbursable = changes.reimbursable;
}
if (changes.attendees && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.ATTENDEES) && shouldShowAttendees(CONST.IOU.TYPE.SUBMIT, transactionPolicy)) {
transactionChanges.attendees = changes.attendees;
}

transactionChanges = removeUnchangedBulkEditFields(transactionChanges, transaction, baseIouReport, transactionPolicy);

Expand Down Expand Up @@ -298,8 +362,18 @@ function updateMultipleMoneyRequests({
updates.reimbursable = transactionChanges.reimbursable;
}

const serializedAttendees = transactionChanges.attendees
? JSON.stringify(
transactionChanges.attendees.map(({avatarUrl, displayName, email}) => ({
avatarUrl,
displayName,
...(email ? {email} : {}),
})),
)
: undefined;

// Skip if no updates
if (Object.keys(updates).length === 0) {
if (Object.keys(updates).length === 0 && !serializedAttendees) {
continue;
}

Expand All @@ -308,7 +382,11 @@ function updateMultipleMoneyRequests({

const optimisticData: Array<
OnyxUpdate<
typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
| typeof ONYXKEYS.NVP_RECENT_ATTENDEES
>
> = [];
const successData: Array<
Expand Down Expand Up @@ -418,6 +496,33 @@ function updateMultipleMoneyRequests({
});
}

if ('attendees' in transactionChanges) {
// Clear overLimit when new attendee count pushes the expense past the per-attendee limit.
const overLimitViolation = currentTransactionViolations?.find((violation) => violation.name === CONST.VIOLATIONS.OVER_LIMIT);
if (overLimitViolation) {
const limitForSingleAttendee = overLimitViolation.data?.amount ?? 0;
if (limitForSingleAttendee * (transactionChanges.attendees?.length ?? 1) > Math.abs(getAmount(transaction))) {
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`,
value: currentTransactionViolations?.filter((violation) => violation.name !== CONST.VIOLATIONS.OVER_LIMIT) ?? [],
});
}
}

optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
key: ONYXKEYS.NVP_RECENT_ATTENDEES,
value: lodashUnionBy(
transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, ...(email ? {email} : {})})) ?? [],
getRecentAttendees(),
// Use || so empty-string emails fall back to displayName for the union key
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
Comment thread
samranahm marked this conversation as resolved.
(attendee) => attendee.email || attendee.displayName,
).slice(0, CONST.IOU.MAX_RECENT_ATTENDEES),
});
}

// Optimistic transaction update
optimisticData.push({
onyxMethod: Onyx.METHOD.MERGE,
Expand Down Expand Up @@ -641,41 +746,38 @@ function updateMultipleMoneyRequests({
});
}

const params = {
transactionID,
reportActionID: modifiedExpenseReportActionID,
updates: JSON.stringify(updates),
const onyxData: BulkEditWriteOnyxData = {
optimisticData: [...optimisticData, ...snapshotOptimisticData],
successData: [...successData, ...snapshotSuccessData],
failureData: [...failureData, ...snapshotFailureData],
};

API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, {
optimisticData: [...optimisticData, ...snapshotOptimisticData] as Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.SNAPSHOT
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
>
>,
successData: [...successData, ...snapshotSuccessData] as Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.SNAPSHOT
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
>
>,
failureData: [...failureData, ...snapshotFailureData] as Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.TRANSACTION
| typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS
| typeof ONYXKEYS.COLLECTION.SNAPSHOT
| typeof ONYXKEYS.COLLECTION.REPORT
| typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS
>
>,
});
const hasGenericUpdates = Object.keys(updates).length > 0;

if (hasGenericUpdates) {
writeBulkEditMoneyRequest(
{
transactionID,
reportActionID: modifiedExpenseReportActionID,
updates: JSON.stringify(updates),
},
onyxData,
);
}

if (serializedAttendees) {
writeBulkEditMoneyRequestAttendees(
{
transactionID,
reportID: iouReport?.reportID,
// UpdateMoneyRequestAttendees does not create transaction threads. Only attach
// reportActionID when a real thread already exists (not one we just seeded locally).
...(!hasGenericUpdates && !didCreateThreadInThisIteration ? {reportActionID: modifiedExpenseReportActionID} : {}),
attendees: serializedAttendees,
},
hasGenericUpdates ? undefined : onyxData,
);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';

import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';

import {updateBulkEditDraftTransaction} from '@libs/actions/IOU/BulkEdit';
import Navigation from '@libs/Navigation/Navigation';

import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttendeeSelector';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Attendee} from '@src/types/onyx/IOU';

import {deepEqual} from 'fast-equals';
import React, {useState} from 'react';

function SearchEditMultipleAttendeesPage() {
const {translate} = useLocalize();
const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`);
const [attendees, setAttendees] = useState<Attendee[]>(() => draftTransaction?.comment?.attendees ?? []);

const saveAttendees = () => {
if (attendees.length <= 0) {
return;
}

const currentAttendees = draftTransaction?.comment?.attendees ?? [];
if (deepEqual(currentAttendees, attendees)) {
Navigation.goBack();
return;
}

updateBulkEditDraftTransaction({comment: {attendees}});
Navigation.goBack();
};

return (
<ScreenWrapper
includeSafeAreaPaddingBottom
shouldEnableMaxHeight
testID="SearchEditMultipleAttendeesPage"
>
<HeaderWithBackButton
title={translate('iou.attendees')}
onBackButtonPress={Navigation.goBack}
/>
<MoneyRequestAttendeeSelector
onFinish={saveAttendees}
onAttendeesAdded={setAttendees}
attendees={attendees}
iouType={CONST.IOU.TYPE.SUBMIT}
shouldDeferEmptySelectionError
/>
</ScreenWrapper>
);
}

export default SearchEditMultipleAttendeesPage;
Loading
Loading