Skip to content

Commit 4f6bfe2

Browse files
authored
Merge pull request Expensify#81885 from Expensify/alberto-columns
Save CSV-import mapped columns for later use
2 parents 2941b1e + 300be0a commit 4f6bfe2

14 files changed

Lines changed: 1129 additions & 54 deletions

File tree

src/CONST/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7983,6 +7983,10 @@ const CONST = {
79837983
ENABLED: 'enabled',
79847984
IGNORE: 'ignore',
79857985
DESTINATION: 'destination',
7986+
CATEGORY: 'category',
7987+
DATE: 'date',
7988+
MERCHANT: 'merchant',
7989+
TRANSACTION_FIELDS: ['date', 'merchant', 'amount', 'category'] as const,
79867990
},
79877991

79887992
IMPORT_SPREADSHEET: {

src/ONYXKEYS.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type * as OnyxTypes from './types/onyx';
99
import type {Attendee, DistanceExpenseType, Participant} from './types/onyx/IOU';
1010
import type Onboarding from './types/onyx/Onboarding';
1111
import type {AnyOnyxUpdate} from './types/onyx/Request';
12+
import type {SavedCSVColumnLayoutList} from './types/onyx/SavedCSVColumnLayout';
1213
import type AssertTypesEqual from './types/utils/AssertTypesEqual';
1314
import type DeepValueOf from './types/utils/DeepValueOf';
1415

@@ -187,6 +188,9 @@ const ONYXKEYS = {
187188
/** This NVP holds to most recent waypoints that a person has used when creating a distance expense */
188189
NVP_RECENT_WAYPOINTS: 'nvp_expensify_recentWaypoints',
189190

191+
/** This NVP contains saved CSV column layouts for imported cards */
192+
NVP_SAVED_CSV_COLUMN_LAYOUT_LIST: 'nvp_expensify_savedCSVColumnLayoutList',
193+
190194
/** This NVP contains the choice that the user made on the engagement modal */
191195
NVP_INTRO_SELECTED: 'nvp_introSelected',
192196

@@ -1291,6 +1295,7 @@ type OnyxValuesMapping = {
12911295
[ONYXKEYS.NVP_LAST_LOCATION_PERMISSION_PROMPT]: string;
12921296
[ONYXKEYS.LAST_EXPORT_METHOD]: OnyxTypes.LastExportMethod;
12931297
[ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[];
1298+
[ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST]: SavedCSVColumnLayoutList;
12941299
[ONYXKEYS.NVP_INTRO_SELECTED]: OnyxTypes.IntroSelected;
12951300
[ONYXKEYS.HAS_NON_PERSONAL_POLICY]: boolean;
12961301
[ONYXKEYS.NVP_LAST_SELECTED_DISTANCE_RATES]: OnyxTypes.LastSelectedDistanceRates;

src/components/ImportColumn.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Str} from 'expensify-common';
2-
import React, {useEffect} from 'react';
2+
import React, {useEffect, useRef} from 'react';
33
import {View} from 'react-native';
44
import useLocalize from '@hooks/useLocalize';
55
import useOnyx from '@hooks/useOnyx';
@@ -27,7 +27,7 @@ function findColumnName(header: string): string {
2727

2828
case 'category':
2929
case 'categories':
30-
attribute = CONST.CSV_IMPORT_COLUMNS.EMAIL;
30+
attribute = CONST.CSV_IMPORT_COLUMNS.CATEGORY;
3131
break;
3232

3333
case 'glcode':
@@ -103,6 +103,19 @@ function findColumnName(header: string): string {
103103
attribute = CONST.CSV_IMPORT_COLUMNS.CURRENCY;
104104
break;
105105

106+
case 'date':
107+
case 'transactiondate':
108+
case 'transaction_date':
109+
attribute = CONST.CSV_IMPORT_COLUMNS.DATE;
110+
break;
111+
112+
case 'merchant':
113+
case 'merchants':
114+
case 'vendor':
115+
case 'vendors':
116+
attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT;
117+
break;
118+
106119
case 'rateid':
107120
attribute = CONST.CSV_IMPORT_COLUMNS.RATE_ID;
108121
break;
@@ -156,6 +169,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex, shouldShowD
156169
const {translate} = useLocalize();
157170
const [spreadsheet] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET);
158171
const {containsHeader = true} = spreadsheet ?? {};
172+
const hasAutoDetected = useRef(false);
159173

160174
const options: Array<DropdownOption<string>> = (columnRoles ?? []).map((item) => ({
161175
text: item.text,
@@ -166,17 +180,27 @@ function ImportColumn({column, columnName, columnRoles, columnIndex, shouldShowD
166180

167181
const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', ');
168182

169-
const colName = findColumnName(column.at(0) ?? '');
170-
const defaultSelectedIndex = columnRoles?.findIndex((item) => item.value === colName);
171-
const finalIndex = defaultSelectedIndex !== -1 ? defaultSelectedIndex : 0;
183+
const currentColumnValue = spreadsheet?.columns?.[columnIndex];
184+
// Treat 'ignore' as unmapped so auto-detection can still run
185+
const isMapped = currentColumnValue && currentColumnValue !== CONST.CSV_IMPORT_COLUMNS.IGNORE;
186+
const autoDetectedColName = isMapped ? '' : findColumnName(column.at(0) ?? '');
187+
188+
const foundIndex = columnRoles?.findIndex((item) => item.value === (currentColumnValue ?? autoDetectedColName)) ?? -1;
189+
const selectedIndex = foundIndex !== -1 ? foundIndex : 0;
172190

173191
useEffect(() => {
174-
if (defaultSelectedIndex === -1) {
192+
// Only run auto-detection once on mount
193+
if (hasAutoDetected.current) {
175194
return;
176195
}
177-
setColumnName(columnIndex, colName);
178-
// eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want this effect to run again
179-
}, []);
196+
197+
if (isMapped || !autoDetectedColName) {
198+
return;
199+
}
200+
201+
hasAutoDetected.current = true;
202+
setColumnName(columnIndex, autoDetectedColName);
203+
}, [isMapped, autoDetectedColName, columnIndex]);
180204

181205
const columnHeader = containsHeader ? column.at(0) : translate('spreadsheet.column', columnName);
182206

@@ -208,7 +232,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex, shouldShowD
208232
onOptionSelected={(option) => {
209233
setColumnName(columnIndex, option.value);
210234
}}
211-
defaultSelectedIndex={finalIndex}
235+
defaultSelectedIndex={selectedIndex}
212236
options={options}
213237
success={false}
214238
/>

src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ function MoneyRequestReportActionsList({
301301
}
302302
return option;
303303
});
304-
}, [originalSelectedTransactionsOptions, dismissedRejectUseExplanation]);
304+
}, [originalSelectedTransactionsOptions, dismissedRejectUseExplanation, isDelegateAccessRestricted, showDelegateNoAccessModal]);
305305

306306
const dismissRejectModalBasedOnAction = useCallback(() => {
307307
if (rejectModalAction === CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK) {

src/components/Search/SearchAutocompleteList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,7 @@ function SearchAutocompleteList({
403403
flatIndex++;
404404
}
405405
}
406-
}, [areOptionsInitialized, firstRecentReportKey, shouldUseNarrowLayout]);
406+
}, [areOptionsInitialized, firstRecentReportKey, sections, shouldUseNarrowLayout]);
407407

408408
useEffect(() => {
409409
const targetText = autocompleteQueryValue;

src/libs/API/parameters/ImportCSVTransactionsParams.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ type ImportCSVTransactionsParams = {
1313

1414
/** Whether transactions are reimbursable */
1515
reimbursable: boolean;
16+
17+
/** Mapping of transaction attributes (amount, merchant, category, date) to column names */
18+
columnMappings: string;
1619
};
1720

1821
export default ImportCSVTransactionsParams;

src/libs/actions/Card.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type {Card, CompanyCardFeedWithDomainID, Report, Transaction} from '@src/
3232
import type {CardLimitType, ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card';
3333
import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails';
3434
import type {ConnectionName} from '@src/types/onyx/Policy';
35+
import type {SavedCSVColumnLayoutData} from '@src/types/onyx/SavedCSVColumnLayout';
3536

3637
type ReplacementReason = 'damaged' | 'stolen';
3738

@@ -1157,13 +1158,14 @@ type DeletePersonalCardData = {
11571158
card?: Card;
11581159
allTransactions: OnyxCollection<Transaction>;
11591160
allReports: OnyxCollection<Report>;
1161+
savedColumnLayout?: SavedCSVColumnLayoutData;
11601162
};
11611163

11621164
/**
11631165
* Deletes a personal card (CSV-imported card) and its associated transactions.
11641166
* The backend will handle deleting transactions on unsubmitted/open reports.
11651167
*/
1166-
function deletePersonalCard({cardID, card, allTransactions, allReports}: DeletePersonalCardData) {
1168+
function deletePersonalCard({cardID, card, allTransactions, allReports, savedColumnLayout}: DeletePersonalCardData) {
11671169
// Find all transactions associated with this card that are on open/unsubmitted reports
11681170
// This matches the backend logic which only deletes transactions on open reports
11691171
const transactionsToDelete: Transaction[] = [];
@@ -1173,18 +1175,25 @@ function deletePersonalCard({cardID, card, allTransactions, allReports}: DeleteP
11731175
}
11741176
}
11751177

1176-
// Optimistically remove the card immediately for instant UI feedback
1177-
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.CARD_LIST | typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [
1178+
// Optimistically remove the card and its saved column layout immediately for instant UI feedback
1179+
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.CARD_LIST | typeof ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST | typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [
11781180
{
11791181
onyxMethod: Onyx.METHOD.MERGE,
11801182
key: ONYXKEYS.CARD_LIST,
11811183
value: {
11821184
[cardID]: null,
11831185
},
11841186
},
1187+
{
1188+
onyxMethod: Onyx.METHOD.MERGE,
1189+
key: ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST,
1190+
value: {
1191+
[cardID]: null,
1192+
},
1193+
},
11851194
];
11861195

1187-
const failureData: Array<OnyxUpdate<typeof ONYXKEYS.CARD_LIST | typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [
1196+
const failureData: Array<OnyxUpdate<typeof ONYXKEYS.CARD_LIST | typeof ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST | typeof ONYXKEYS.COLLECTION.TRANSACTION>> = [
11881197
{
11891198
onyxMethod: Onyx.METHOD.MERGE,
11901199
key: ONYXKEYS.CARD_LIST,
@@ -1198,6 +1207,17 @@ function deletePersonalCard({cardID, card, allTransactions, allReports}: DeleteP
11981207
},
11991208
];
12001209

1210+
// Restore the saved column layout on failure if it existed
1211+
if (savedColumnLayout) {
1212+
failureData.push({
1213+
onyxMethod: Onyx.METHOD.MERGE,
1214+
key: ONYXKEYS.NVP_SAVED_CSV_COLUMN_LAYOUT_LIST,
1215+
value: {
1216+
[cardID]: savedColumnLayout,
1217+
},
1218+
});
1219+
}
1220+
12011221
// Optimistically delete transactions and prepare failure data to restore them
12021222
for (const transaction of transactionsToDelete) {
12031223
optimisticData.push({

src/libs/actions/ImportSpreadsheet.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Onyx from 'react-native-onyx';
22
import CONST from '@src/CONST';
33
import ONYXKEYS from '@src/ONYXKEYS';
44
import type {ImportTransactionSettings} from '@src/types/onyx/ImportedSpreadsheet';
5+
import type {SavedCSVColumnLayoutData} from '@src/types/onyx/SavedCSVColumnLayout';
56

67
function setSpreadsheetData(
78
data: string[][],
@@ -103,4 +104,53 @@ function setImportTransactionSettings(cardDisplayName: string, currency: string,
103104
});
104105
}
105106

106-
export {setSpreadsheetData, setColumnName, closeImportPage, setContainsHeader, setImportTransactionCardName, setImportTransactionCurrency, setImportTransactionSettings};
107+
/**
108+
* Applies saved column mappings to the spreadsheet data if the column headers match.
109+
* This is used when importing transactions to an existing card that has a saved layout.
110+
*
111+
* @param spreadsheetData - The spreadsheet data in column-major format
112+
* @param savedLayout - The saved column layout for this card
113+
*/
114+
function applySavedColumnMappings(spreadsheetData: string[][], savedLayout: SavedCSVColumnLayoutData): void {
115+
const columnMapping = savedLayout?.columnMapping;
116+
if (!columnMapping?.names) {
117+
return;
118+
}
119+
const savedNames = columnMapping.names;
120+
121+
const headerToIndex: Record<string, number> = {};
122+
for (const [index, column] of spreadsheetData.entries()) {
123+
const headerName = column.at(0)?.trim();
124+
if (headerName) {
125+
headerToIndex[headerName] = index;
126+
}
127+
}
128+
129+
const columnUpdates: Record<number, string> = {};
130+
131+
for (const role of CONST.CSV_IMPORT_COLUMNS.TRANSACTION_FIELDS) {
132+
const savedColumnName = savedNames[role];
133+
if (typeof savedColumnName !== 'string') {
134+
continue;
135+
}
136+
const trimmedName = savedColumnName.trim();
137+
if (trimmedName && headerToIndex[trimmedName] !== undefined) {
138+
columnUpdates[headerToIndex[trimmedName]] = role;
139+
}
140+
}
141+
142+
if (Object.keys(columnUpdates).length > 0) {
143+
Onyx.merge(ONYXKEYS.IMPORTED_SPREADSHEET, {columns: columnUpdates});
144+
}
145+
}
146+
147+
export {
148+
setSpreadsheetData,
149+
setColumnName,
150+
closeImportPage,
151+
setContainsHeader,
152+
setImportTransactionCardName,
153+
setImportTransactionCurrency,
154+
setImportTransactionSettings,
155+
applySavedColumnMappings,
156+
};

0 commit comments

Comments
 (0)