Repository navigation
Allow mapping a Tag column in the personal card CSV transaction import - #102242
Conversation
The personal card import only offered Ignore, Date, Merchant, Category and Amount, and the import action only read those fields, so a Tag column was dropped. Add a Tag role, carry the tag through to the API and the optimistic transaction, save it in the column layout, auto-map Label/Labels headers like Classic, and block the import when a tag exceeds the API length limit. Co-authored-by: Kevin Brian Bader <ikevin127@users.noreply.github.com>
🦜 Polyglot Parrot! 🦜Squawk! Looks like you added some shiny new English strings. Allow me to parrot them back to you in other tongues: View the translation diffdiff --git a/src/languages/de.ts b/src/languages/de.ts
index 288f72f0..60bfc5ed 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1195,7 +1195,7 @@ const translations: TranslationDeepObject<typeof en> = {
singleFieldMultipleColumns: (fieldName: string) => `Ups! Du hast ein einzelnes Feld („${fieldName}“) mehreren Spalten zugeordnet. Bitte überprüfe dies und versuche es erneut.`,
emptyMappedField: (fieldName: string) => `Ups! Das Feld („${fieldName}“) enthält einen oder mehrere leere Werte. Bitte überprüfe es und versuche es erneut.`,
fieldValueTooLong: (fieldName: string, limit: number) =>
- `Ups! Das Feld („${fieldName}“) enthält einen oder mehrere Werte mit mehr als ${limit} Zeichen. Bitte überprüfe es und versuche es erneut.`,
+ `Ups! Das Feld („${fieldName}“) enthält einen oder mehrere Werte, die länger als ${limit} Zeichen sind. Bitte überprüfen Sie die Eingaben und versuchen Sie es erneut.`,
importSuccessfulTitle: 'Import erfolgreich',
importCategoriesNoneAddedOrUpdated: 'Es wurden keine Kategorien hinzugefügt oder aktualisiert.',
importCategoriesAdded: ({count}: {count: number}) => ({
diff --git a/src/languages/el.ts b/src/languages/el.ts
index 73b32d94..428d1ec8 100644
--- a/src/languages/el.ts
+++ b/src/languages/el.ts
@@ -1240,7 +1240,7 @@ const translations: TranslationDeepObject<typeof en> = {
singleFieldMultipleColumns: (fieldName: string) => `Ωχ! Έχετε αντιστοιχίσει ένα μόνο πεδίο («${fieldName}») σε πολλές στήλες. Παρακαλούμε ελέγξτε και δοκιμάστε ξανά.`,
emptyMappedField: (fieldName: string) => `Ωχ! Το πεδίο («${fieldName}») περιέχει μία ή περισσότερες κενές τιμές. Παρακαλούμε ελέγξτε και δοκιμάστε ξανά.`,
fieldValueTooLong: (fieldName: string, limit: number) =>
- `Ωχ! Το πεδίο («${fieldName}») περιέχει μία ή περισσότερες τιμές μεγαλύτερες από ${limit} χαρακτήρες. Παρακαλούμε ελέγξτε και δοκιμάστε ξανά.`,
+ `Ουπς! Το πεδίο («${fieldName}») περιέχει μία ή περισσότερες τιμές με μήκος μεγαλύτερο από ${limit} χαρακτήρες. Παρακαλούμε ελέγξτε και δοκιμάστε ξανά.`,
importSuccessfulTitle: 'Η εισαγωγή ήταν επιτυχής',
importCategoriesNoneAddedOrUpdated: 'Δεν έχουν προστεθεί ή ενημερωθεί κατηγορίες.',
importCategoriesAdded: ({count}: {count: number}) => ({
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 14276e56..000d8316 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1189,7 +1189,8 @@ const translations: TranslationDeepObject<typeof en> = {
fieldNotMapped: (fieldName) => `¡Vaya! Un campo obligatorio ("${fieldName}") no ha sido mapeado. Por favor, revisa e inténtalo de nuevo.`,
singleFieldMultipleColumns: (fieldName) => `¡Vaya! Has mapeado un solo campo ("${fieldName}") a varias columnas. Por favor, revisa e inténtalo de nuevo.`,
emptyMappedField: (fieldName) => `¡Vaya! El campo ("${fieldName}") contiene uno o más valores vacíos. Por favor, revísalo e inténtalo de nuevo.`,
- fieldValueTooLong: (fieldName, limit) => `¡Vaya! El campo ("${fieldName}") contiene uno o más valores de más de ${limit} caracteres. Por favor, revísalo e inténtalo de nuevo.`,
+ fieldValueTooLong: (fieldName: string, limit: number) =>
+ `¡Ups! El campo ("${fieldName}") contiene uno o más valores de más de ${limit} caracteres. Por favor, revísalo e inténtalo de nuevo.`,
importFailedTitle: 'Fallo en la importación',
importFailedDescription: 'Por favor, asegúrate de que todos los campos estén llenos correctamente e inténtalo de nuevo. Si el problema persiste, por favor contacta a Concierge.',
importCategoriesNoneAddedOrUpdated: 'No se han añadido ni actualizado categorías.',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 8e26238b..e70bd620 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1201,7 +1201,7 @@ const translations: TranslationDeepObject<typeof en> = {
singleFieldMultipleColumns: (fieldName: string) => `Oups ! Vous avez associé un seul champ (« ${fieldName} ») à plusieurs colonnes. Veuillez vérifier et réessayer.`,
emptyMappedField: (fieldName: string) => `Oups ! Le champ (« ${fieldName} ») contient une ou plusieurs valeurs vides. Veuillez vérifier et réessayer.`,
fieldValueTooLong: (fieldName: string, limit: number) =>
- `Oups ! Le champ (« ${fieldName} ») contient une ou plusieurs valeurs de plus de ${limit} caractères. Veuillez vérifier et réessayer.`,
+ `Oups ! Le champ (« ${fieldName} ») contient une ou plusieurs valeurs de plus de ${limit} caractères. Veuillez vérifier et réessayer.`,
importSuccessfulTitle: 'Importation réussie',
importCategoriesNoneAddedOrUpdated: 'Aucune catégorie n’a été ajoutée ou mise à jour.',
importCategoriesAdded: ({count}: {count: number}) => ({
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index 0a6b6854..9360b73b 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1180,7 +1180,8 @@ const translations: TranslationDeepObject<typeof en> = {
fieldNotMapped: (fieldName: string) => `おっと!必須フィールド(「${fieldName}」)がマッピングされていません。確認してもう一度お試しください。`,
singleFieldMultipleColumns: (fieldName: string) => `おっと!1 つのフィールド(「${fieldName}」)を複数の列に割り当てています。確認してもう一度お試しください。`,
emptyMappedField: (fieldName: string) => `おっと!フィールド(「${fieldName}」)に1つ以上の空の値が含まれています。確認してもう一度お試しください。`,
- fieldValueTooLong: (fieldName: string, limit: number) => `おっと!フィールド(「${fieldName}」)に${limit}文字を超える値が1つ以上含まれています。確認してもう一度お試しください。`,
+ fieldValueTooLong: (fieldName: string, limit: number) =>
+ `おっと!フィールド(「${fieldName}」)に、${limit}文字を超える値が1つ以上含まれています。確認してから、もう一度お試しください。`,
importSuccessfulTitle: 'インポートに成功しました',
importCategoriesNoneAddedOrUpdated: 'カテゴリーは追加も更新もされていません。',
importCategoriesAdded: ({count}: {count: number}) => ({
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 3540fe40..934722b2 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1194,7 +1194,7 @@ const translations: TranslationDeepObject<typeof en> = {
singleFieldMultipleColumns: (fieldName: string) => `Oeps! Je hebt één veld (‘${fieldName}’) aan meerdere kolommen gekoppeld. Controleer dit en probeer het opnieuw.`,
emptyMappedField: (fieldName: string) => `Oeps! Het veld („${fieldName}”) bevat een of meer lege waarden. Controleer het en probeer het opnieuw.`,
fieldValueTooLong: (fieldName: string, limit: number) =>
- `Oeps! Het veld („${fieldName}”) bevat een of meer waarden die langer zijn dan ${limit} tekens. Controleer het en probeer het opnieuw.`,
+ `Oeps! Het veld ("${fieldName}") bevat één of meer waarden die langer zijn dan ${limit} tekens. Controleer dit en probeer het opnieuw.`,
importSuccessfulTitle: 'Import geslaagd',
importCategoriesNoneAddedOrUpdated: 'Er zijn geen categorieën toegevoegd of bijgewerkt.',
importCategoriesAdded: ({count}: {count: number}) => ({
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index f0a055e5..50536fae 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1145,7 +1145,7 @@ const translations: TranslationDeepObject<typeof en> = {
fieldNotMapped: (fieldName: string) => `哎呀!有一个必填字段(“${fieldName}”)尚未映射。请检查后重试。`,
singleFieldMultipleColumns: (fieldName: string) => `哎呀!你已将单个字段(“${fieldName}”)映射到多个列。请检查后重试。`,
emptyMappedField: (fieldName: string) => `哎呀!字段(“${fieldName}”)包含一个或多个空值。请检查后重试。`,
- fieldValueTooLong: (fieldName: string, limit: number) => `哎呀!字段(“${fieldName}”)包含一个或多个超过 ${limit} 个字符的值。请检查后重试。`,
+ fieldValueTooLong: (fieldName: string, limit: number) => `哎呀!字段(“${fieldName}”)中包含一个或多个超过 ${limit} 个字符的值。请检查后重试。`,
importSuccessfulTitle: '导入成功',
importCategoriesNoneAddedOrUpdated: '尚未添加或更新任何类别。',
importCategoriesAdded: ({count}: {count: number}) => ({
Note You can apply these changes to your branch by copying the patch to your clipboard, then running |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
🤖 Web test steps verification results: I reached the CSV upload screen, but I couldn't test the Tag mapping, import, or length error on Web because the web test driver can't upload files. Steps 2–6 were not run, so they are unverified rather than failing; the Android run passed all of them.
|
This comment has been minimized.
This comment has been minimized.
|
I updated the PR description, but I couldn't mark the PR ready for review. That step wasn't permitted in this run, so please select Ready for review yourself. Changes:
I've only verified this on Android (see the Android results comment above). The boxes for iOS, mWeb and macOS are checked at your request, and the Screenshots/Videos section is still empty. |
|
@MelvinBot Mark the PR ready for review. |
|
I couldn't mark this PR ready for review. The command to change its draft status was blocked in my environment, so the PR is still a draft. You're an assignee on this PR, so you can select Ready for review at the bottom of the PR page. Before you do, fill in the Tests and QA Steps sections. They still hold the TODO placeholders for the human co-author. |
This comment has been minimized.
This comment has been minimized.
|
@MelvinBot I don't have write access to the repo so I can't mark a draft PR as ready for review - you do that usually on draft PRs without any issue, you should mark it ready for review or tag somebody with write access to do it. |
|
I couldn't mark this PR ready for review. My environment blocked the command again, so the PR is still a draft. @puneetlath, you own the linked issue and have write access. Could you select Ready for review on this PR? The Tests and QA Steps sections are filled in, and the Android test steps passed. |
|
@ikevin127 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7ff752aa5c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const tagValues = data?.at(tagColumnIndex)?.slice(containsHeader ? 1 : 0) ?? []; | ||
| return tagValues.some((tagValue) => [...String(tagValue ?? '')].length > CONST.API_TRANSACTION_TAG_MAX_LENGTH); |
There was a problem hiding this comment.
Limit tag validation to rows that are imported
When a CSV contains a skipped row—such as a footer or malformed record with no date/amount, or an invalid date—and that row's tag is over 255 characters, this scan blocks the entire upload. buildTransactionListFromSpreadsheet discards those rows before sending transactionList, so the oversized value cannot reach the API; validate only rows eligible for import (or the parsed transaction list).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@MelvinBot Address this comment and, if buildTransactionListFromSpreadsheet is something we already use in similar flows to discard bad rows before sending transactionList, then apply the same fix in this case - otherwise let us know whether we should flag this with product / design.
There was a problem hiding this comment.
Fixed in 74e7992. The tag length check now only looks at rows that will be imported, so you don't need to raise this with product or design.
buildTransactionListFromSpreadsheet isn't new in this PR. It's already on main, and it's the function this import uses to build transactionList (ImportTransactions.ts:301 on main). It skips rows with no date, no amount, or an invalid date. Now hasTagExceedingMaxLength runs on its output instead of on the raw Tag column. A long tag on a footer or other skipped row no longer blocks the import.
I added a unit test for this case. ImportTransactions.test.ts (58 tests), lint and typecheck pass.
JmillsExpensify
left a comment
There was a problem hiding this comment.
Didn't test the PR though the change explanation and testing steps LGTM. Customer demand is driving this one, so product approved change.
Co-authored-by: Kevin Brian Bader <ikevin127@users.noreply.github.com>
|
@puneetlath Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button] |
Reviewer Checklist
Screenshots/VideosScreen.Recording.2026-09-25.at.16.27.13.mov |
|
No code fix is needed. None of the failing checks point to a problem in this PR's code.
If the in-progress Reassure run fails, tag me again and I'll look at it. |
ikevin127
left a comment
There was a problem hiding this comment.
🟢 LGTM - Tests well, clean code
|
🚧 puneetlath has triggered a test Expensify/App build. You can view the workflow run here. |
|
✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release. |
|
🧪🧪 Use the links below to test this adhoc build on Android, iOS, and Web. Happy testing! 🧪🧪
|
|
🚀 Deployed to staging by https://github.com/puneetlath in version: 9.4.95-0 🚀
|
|
Yes, this PR needs a help site update. I opened a draft PR: Docs updates for E/A#102242 Add Tag column to personal card spreadsheet import The help article that covers this import only listed the required Date, Merchant, and Amount columns: Import-Personal-Card-Transactions-From-a-Spreadsheet.md. The draft PR:
What I checked
@ikevin127, please review the linked help site PR and confirm it reflects the current behavior. Then mark the linked help site PR |
|
🚀 Deployed to production by https://github.com/Julesssss in version: 9.4.95-4 🚀
|











Explanation of Change
The personal card CSV import (Account > Wallet > Import transactions) had no Tag option in the column dropdown, and the import action only read date, merchant, amount and category. So a Tag column always showed "Ignore", and imported expenses had no tag. Classic supports this, and the backend
ImportCSVTransactionscommand already stores atagon each row, so this is a frontend-only change.This PR carries Tag through the same path Category already uses:
TransactionsImportedPage. "Tag" and "Tags" headers now auto-map to it through the existingNAME -> TAGremap.tagtoCSV_IMPORT_COLUMNS.TRANSACTION_FIELDSand toImportTransactions.ts, so each row's tag is sent intransactionList, set on the optimistic transaction, and saved in the column layout. BecauseapplySavedColumnMappingsloops overTRANSACTION_FIELDS, a saved Tag mapping is restored when a file is uploaded again to the same card.tagto the saved layout types (ColumnMappingIndexes/ColumnMappingNames), matching Classic'stagattribute.402 tag too large.). Instead of sending them and getting a vague failure, the mapping page now blocks the import with a clear error when any value in the Tag column is longer thanCONST.API_TRANSACTION_TAG_MAX_LENGTH. This uses a newspreadsheet.fieldValueTooLongmessage, translated in all locales.A colon-delimited value like
Dept:Projectbecomes a multi-level tag, the same as the company card import. Like Category, the tag is free text and isn't checked against a policy at import time.Unit tests cover the tag index, the saved layout and restore, the transaction list (including multi-level and empty values), the API payload and optimistic transaction, the length check, and the Label/Labels header mapping.
AI Tests:
npm run typecheck,npm run lint(changed files),npm run spell-changed,npm run fmt, and the React Compiler compliance check onTransactionsImportedPage.tsxall passed.npm testfor the import, spreadsheet and localization suites passed (14 suites, 188 tests). An automated browser check couldn't run because the test session was stuck on a blank page, so this hasn't been verified in the UI yet.Fixed Issues
$ #102232
PROPOSAL: #102232 (comment)
Tests
Precondition: a workspace with Tags enabled, and a CSV file with the headers
Date,Merchant,Amount,Tagand at least one row, for example2026-09-01,Coffee Shop,4.50,CardA.Offline tests
QA Steps
Precondition: a workspace with Tags enabled, and a CSV file with the headers
Date,Merchant,Amount,Tagand at least one row, for example2026-09-01,Coffee Shop,4.50,CardA.PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari