diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/CheckboxOption.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/CheckboxOption.jsx
new file mode 100644
index 00000000000..76e1e7fb915
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/CheckboxOption.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import { theme } from '../../../style';
+import { View } from '../../common/View';
+import { Checkbox } from '../../forms';
+
+export function CheckboxOption({
+ id,
+ checked,
+ disabled,
+ onChange,
+ children,
+ style,
+}) {
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/DateFormatSelect.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/DateFormatSelect.jsx
new file mode 100644
index 00000000000..c911ea36c66
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/DateFormatSelect.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+
+import { Select } from '../../common/Select';
+import { View } from '../../common/View';
+import { SectionLabel } from '../../forms';
+
+import { dateFormats } from './utils';
+
+export function DateFormatSelect({
+ transactions,
+ fieldMappings,
+ parseDateFormat,
+ onChange,
+}) {
+ // We don't actually care about the delimiter, but we try to render
+ // it based on the data we have so far. Look in a transaction and
+ // try to figure out what delimiter the date is using, and default
+ // to space if we can't figure it out.
+ let delimiter = '-';
+ if (transactions.length > 0 && fieldMappings && fieldMappings.date != null) {
+ const date = transactions[0][fieldMappings.date];
+ const m = date && date.match(/[/.,-/\\]/);
+ delimiter = m ? m[0] : ' ';
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal/FieldMappings.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/FieldMappings.jsx
new file mode 100644
index 00000000000..b4de3f32617
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/FieldMappings.jsx
@@ -0,0 +1,132 @@
+import React from 'react';
+
+import { Stack } from '../../common/Stack';
+import { View } from '../../common/View';
+import { SectionLabel } from '../../forms';
+
+import { SelectField } from './SelectField';
+import { SubLabel } from './SubLabel';
+
+export function FieldMappings({
+ transactions,
+ mappings,
+ onChange,
+ splitMode,
+ inOutMode,
+ hasHeaderRow,
+}) {
+ if (transactions.length === 0) {
+ return null;
+ }
+
+ const { existing, ignored, selected, selected_merge, trx_id, ...trans } =
+ transactions[0];
+ const options = Object.keys(trans);
+ mappings = mappings || {};
+
+ return (
+
+
+
+
+
+ onChange('date', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+
+
+ onChange('payee', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+
+
+ onChange('notes', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+
+
+ onChange('category', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+ {splitMode ? (
+ <>
+
+
+ onChange('outflow', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+
+
+ onChange('inflow', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+ >
+ ) : (
+ <>
+ {inOutMode && (
+
+
+ onChange('inOut', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+ )}
+
+
+ onChange('amount', name)}
+ hasHeaderRow={hasHeaderRow}
+ firstTransaction={transactions[0]}
+ />
+
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/desktop-client/src/components/modals/ImportTransactionsModal.jsx b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx
similarity index 59%
rename from packages/desktop-client/src/components/modals/ImportTransactionsModal.jsx
rename to packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx
index 4a74a10b70f..903957ed4e8 100644
--- a/packages/desktop-client/src/components/modals/ImportTransactionsModal.jsx
+++ b/packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.jsx
@@ -1,132 +1,35 @@
-import React, { useState, useEffect, useMemo, useCallback } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
-import * as d from 'date-fns';
import deepEqual from 'deep-equal';
-import { format as formatDate_ } from 'loot-core/src/shared/months';
+import { amountToInteger } from 'loot-core/src/shared/util';
+
+import { useActions } from '../../../hooks/useActions';
+import { useDateFormat } from '../../../hooks/useDateFormat';
+import { useSyncedPrefs } from '../../../hooks/useSyncedPrefs';
+import { theme } from '../../../style';
+import { Button, ButtonWithLoading } from '../../common/Button2';
+import { Input } from '../../common/Input';
+import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
+import { Select } from '../../common/Select';
+import { Stack } from '../../common/Stack';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
+import { SectionLabel } from '../../forms';
+import { TableHeader, TableWithNavigator } from '../../table';
+
+import { CheckboxOption } from './CheckboxOption';
+import { DateFormatSelect } from './DateFormatSelect';
+import { FieldMappings } from './FieldMappings';
+import { InOutOption } from './InOutOption';
+import { MultiplierOption } from './MultiplierOption';
+import { Transaction } from './Transaction';
import {
- amountToCurrency,
- amountToInteger,
- looselyParseAmount,
-} from 'loot-core/src/shared/util';
-
-import { useActions } from '../../hooks/useActions';
-import { useDateFormat } from '../../hooks/useDateFormat';
-import { useSyncedPrefs } from '../../hooks/useSyncedPrefs';
-import { SvgDownAndRightArrow } from '../../icons/v2';
-import { theme, styles } from '../../style';
-import { Button, ButtonWithLoading } from '../common/Button2';
-import { Input } from '../common/Input';
-import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
-import { Select } from '../common/Select';
-import { Stack } from '../common/Stack';
-import { Text } from '../common/Text';
-import { Tooltip } from '../common/Tooltip';
-import { View } from '../common/View';
-import { Checkbox, SectionLabel } from '../forms';
-import { TableHeader, TableWithNavigator, Row, Field } from '../table';
-
-const dateFormats = [
- { format: 'yyyy mm dd', label: 'YYYY MM DD' },
- { format: 'yy mm dd', label: 'YY MM DD' },
- { format: 'mm dd yyyy', label: 'MM DD YYYY' },
- { format: 'mm dd yy', label: 'MM DD YY' },
- { format: 'dd mm yyyy', label: 'DD MM YYYY' },
- { format: 'dd mm yy', label: 'DD MM YY' },
-];
-
-export function parseDate(str, order) {
- if (typeof str !== 'string') {
- return null;
- }
-
- function pad(v) {
- return v && v.length === 1 ? '0' + v : v;
- }
-
- const dateGroups = (a, b) => str => {
- const parts = str
- .replace(/\bjan(\.|uary)?\b/i, '01')
- .replace(/\bfeb(\.|ruary)?\b/i, '02')
- .replace(/\bmar(\.|ch)?\b/i, '03')
- .replace(/\bapr(\.|il)?\b/i, '04')
- .replace(/\bmay\.?\b/i, '05')
- .replace(/\bjun(\.|e)?\b/i, '06')
- .replace(/\bjul(\.|y)?\b/i, '07')
- .replace(/\baug(\.|ust)?\b/i, '08')
- .replace(/\bsep(\.|tember)?\b/i, '09')
- .replace(/\boct(\.|ober)?\b/i, '10')
- .replace(/\bnov(\.|ember)?\b/i, '11')
- .replace(/\bdec(\.|ember)?\b/i, '12')
- .replace(/^[^\d]+/, '')
- .replace(/[^\d]+$/, '')
- .split(/[^\d]+/);
- if (parts.length >= 3) {
- return parts.slice(0, 3);
- }
-
- const digits = str.replace(/[^\d]/g, '');
- return [digits.slice(0, a), digits.slice(a, a + b), digits.slice(a + b)];
- };
- const yearFirst = dateGroups(4, 2);
- const twoDig = dateGroups(2, 2);
-
- let parts, year, month, day;
- switch (order) {
- case 'dd mm yyyy':
- parts = twoDig(str);
- year = parts[2];
- month = parts[1];
- day = parts[0];
- break;
- case 'dd mm yy':
- parts = twoDig(str);
- year = `20${parts[2]}`;
- month = parts[1];
- day = parts[0];
- break;
- case 'yyyy mm dd':
- parts = yearFirst(str);
- year = parts[0];
- month = parts[1];
- day = parts[2];
- break;
- case 'yy mm dd':
- parts = twoDig(str);
- year = `20${parts[0]}`;
- month = parts[1];
- day = parts[2];
- break;
- case 'mm dd yy':
- parts = twoDig(str);
- year = `20${parts[2]}`;
- month = parts[0];
- day = parts[1];
- break;
- default:
- case 'mm dd yyyy':
- parts = twoDig(str);
- year = parts[2];
- month = parts[0];
- day = parts[1];
- }
-
- const parsed = `${year}-${pad(month)}-${pad(day)}`;
- if (!d.isValid(d.parseISO(parsed))) {
- return null;
- }
- return parsed;
-}
-
-function formatDate(date, format) {
- if (!date) {
- return null;
- }
- try {
- return formatDate_(date, format);
- } catch (e) {}
- return null;
-}
+ applyFieldMappings,
+ dateFormats,
+ parseAmountFields,
+ parseDate,
+} from './utils';
function getFileType(filepath) {
const m = filepath.match(/\.([^.]*)$/);
@@ -136,30 +39,6 @@ function getFileType(filepath) {
return rawType;
}
-function ParsedDate({ parseDateFormat, dateFormat, date }) {
- const parsed =
- date &&
- formatDate(
- parseDateFormat ? parseDate(date, parseDateFormat) : date,
- dateFormat,
- );
- return (
-
-
- {date || (
-
- Empty
-
- )}{' '}
- →{' '}
-
-
- {parsed || 'Invalid'}
-
-
- );
-}
-
function getInitialDateFormat(transactions, mappings) {
if (transactions.length === 0 || mappings.date == null) {
return 'yyyy mm dd';
@@ -240,87 +119,6 @@ function getInitialMappings(transactions) {
};
}
-function applyFieldMappings(transaction, mappings) {
- const result = {};
- for (const [originalField, target] of Object.entries(mappings)) {
- let field = originalField;
- if (field === 'payee') {
- field = 'payee_name';
- }
-
- result[field] = transaction[target || field];
- }
- // Keep preview fields on the mapped transactions
- result.trx_id = transaction.trx_id;
- result.existing = transaction.existing;
- result.ignored = transaction.ignored;
- result.selected = transaction.selected;
- result.selected_merge = transaction.selected_merge;
- return result;
-}
-
-function parseAmount(amount, mapper, multiplier) {
- if (amount == null) {
- return null;
- }
-
- const parsed =
- typeof amount === 'string' ? looselyParseAmount(amount) : amount;
-
- if (parsed === null) {
- return null;
- }
-
- return mapper(parsed) * multiplier;
-}
-
-function parseAmountFields(
- trans,
- splitMode,
- inOutMode,
- outValue,
- flipAmount,
- multiplierAmount,
-) {
- const multiplier = parseFloat(multiplierAmount) || 1.0;
-
- if (splitMode) {
- // Split mode is a little weird; first we look for an outflow and
- // if that has a value, we never want to show a number in the
- // inflow. Same for `amount`; we choose outflow first and then inflow
- const outflow = parseAmount(trans.outflow, n => -Math.abs(n), multiplier);
- const inflow = outflow
- ? 0
- : parseAmount(trans.inflow, n => Math.abs(n), multiplier);
-
- return {
- amount: outflow || inflow,
- outflow,
- inflow,
- };
- }
- if (inOutMode) {
- return {
- amount: parseAmount(
- trans.amount,
- n => (trans.inOut === outValue ? Math.abs(n) * -1 : Math.abs(n)),
- multiplier,
- ),
- outflow: null,
- inflow: null,
- };
- }
- return {
- amount: parseAmount(
- trans.amount,
- n => (flipAmount ? n * -1 : n),
- multiplier,
- ),
- outflow: null,
- inflow: null,
- };
-}
-
function parseCategoryFields(trans, categories) {
let match = null;
categories.forEach(category => {
@@ -334,511 +132,6 @@ function parseCategoryFields(trans, categories) {
return match;
}
-function Transaction({
- transaction: rawTransaction,
- fieldMappings,
- showParsed,
- parseDateFormat,
- dateFormat,
- splitMode,
- inOutMode,
- outValue,
- flipAmount,
- multiplierAmount,
- categories,
- onCheckTransaction,
- reconcile,
-}) {
- const categoryList = categories.map(category => category.name);
- const transaction = useMemo(
- () =>
- fieldMappings && !rawTransaction.isMatchedTransaction
- ? applyFieldMappings(rawTransaction, fieldMappings)
- : rawTransaction,
- [rawTransaction, fieldMappings],
- );
-
- let amount, outflow, inflow;
- if (rawTransaction.isMatchedTransaction) {
- amount = rawTransaction.amount;
- if (splitMode) {
- outflow = amount < 0 ? -amount : 0;
- inflow = amount > 0 ? amount : 0;
- }
- } else {
- ({ amount, outflow, inflow } = parseAmountFields(
- transaction,
- splitMode,
- inOutMode,
- outValue,
- flipAmount,
- multiplierAmount,
- ));
- }
-
- return (
-
- {reconcile && (
-
- {!transaction.isMatchedTransaction && (
-
- onCheckTransaction(transaction.trx_id)}
- style={
- transaction.selected_merge
- ? {
- ':checked': {
- '::after': {
- background:
- theme.checkboxBackgroundSelected +
- // update sign from packages/desktop-client/src/icons/v1/layer.svg
- // eslint-disable-next-line rulesdir/typography
- ' url(\'data:image/svg+xml; utf8,\') 9px 9px',
- },
- },
- }
- : {
- '&': {
- border:
- '1px solid ' + theme.buttonNormalDisabledBorder,
- backgroundColor: theme.buttonNormalDisabledBorder,
- '::after': {
- display: 'block',
- background:
- theme.buttonNormalDisabledBorder +
- // minus sign adapted from packages/desktop-client/src/icons/v1/add.svg
- // eslint-disable-next-line rulesdir/typography
- ' url(\'data:image/svg+xml; utf8,\') 9px 9px',
- width: 9,
- height: 9,
- content: ' ',
- },
- },
- ':checked': {
- border: '1px solid ' + theme.checkboxBorderSelected,
- backgroundColor: theme.checkboxBackgroundSelected,
- '::after': {
- background:
- theme.checkboxBackgroundSelected +
- // plus sign from packages/desktop-client/src/icons/v1/add.svg
- // eslint-disable-next-line rulesdir/typography
- ' url(\'data:image/svg+xml; utf8,\') 9px 9px',
- },
- },
- }
- }
- />
-
- )}
-
- )}
-
- {transaction.isMatchedTransaction ? (
-
-
-
-
-
- {formatDate(transaction.date, dateFormat)}
-
-
- ) : showParsed ? (
-
- ) : (
- formatDate(transaction.date, dateFormat)
- )}
-
-
- {transaction.payee_name}
-
-
- {transaction.notes}
-
-
- {categoryList.includes(transaction.category) && transaction.category}
-
- {splitMode ? (
- <>
-
- {amountToCurrency(outflow)}
-
-
- {amountToCurrency(inflow)}
-
- >
- ) : (
- <>
- {inOutMode && (
-
- {transaction.inOut}
-
- )}
-
- {amountToCurrency(amount)}
-
- >
- )}
-
- );
-}
-
-function SubLabel({ title }) {
- return (
-
- {title}
-
- );
-}
-
-function SelectField({
- style,
- options,
- value,
- onChange,
- hasHeaderRow,
- firstTransaction,
-}) {
- return (
-