import React, { useState, useRef, useMemo, useCallback, useLayoutEffect, useEffect, useContext, useReducer } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { format as formatDate, parseISO, isValid as isDateValid } from 'date-fns'; import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { getAccountsById, getPayeesById, getCategoriesById } from 'loot-core/src/client/reducers/queries'; import evalArithmetic from 'loot-core/src/shared/arithmetic'; import { currentDay } from 'loot-core/src/shared/months'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { splitTransaction, updateTransaction, deleteTransaction, addSplitTransaction } from 'loot-core/src/shared/transactions'; import { integerToCurrency, amountToInteger, titleFirst } from 'loot-core/src/shared/util'; import AccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; import CategoryAutocomplete from 'loot-design/src/components/CategorySelect'; import { View, Text, Tooltip, Button } from 'loot-design/src/components/common'; import DateSelect from 'loot-design/src/components/DateSelect'; import PayeeAutocomplete from 'loot-design/src/components/PayeeAutocomplete'; import { Cell, Field, Row, InputCell, SelectCell, DeleteCell, CustomCell, CellButton, useTableNavigator, Table } from 'loot-design/src/components/table'; import { useMergedRefs } from 'loot-design/src/components/useMergedRefs'; import { useSelectedDispatch, useSelectedItems } from 'loot-design/src/components/useSelected'; import { styles, colors } from 'loot-design/src/style'; import LeftArrow2 from 'loot-design/src/svg/LeftArrow2'; import RightArrow2 from 'loot-design/src/svg/RightArrow2'; import CheveronDown from 'loot-design/src/svg/v1/CheveronDown'; import ArrowsSynchronize from 'loot-design/src/svg/v2/ArrowsSynchronize'; import CalendarIcon from 'loot-design/src/svg/v2/Calendar'; import Hyperlink2 from 'loot-design/src/svg/v2/Hyperlink2'; import { getStatusProps } from '../schedules/StatusBadge'; let TABLE_BACKGROUND_COLOR = colors.n11; function getDisplayValue(obj, name) { return obj ? obj[name] : ''; } function serializeTransaction(transaction, showZeroInDeposit, dateFormat) { let { amount, date } = transaction; if (isPreviewId(transaction.id)) { amount = getScheduledAmount(amount); } let debit = amount < 0 ? -amount : null; let credit = amount > 0 ? amount : null; if (amount === 0) { if (showZeroInDeposit) { credit = 0; } else { debit = 0; } } // Validate the date format if (!isDateValid(parseISO(date))) { // Be a little forgiving if the date isn't valid. This at least // stops the UI from crashing, but this is a serious problem with // the data. This allows the user to go through and see empty // dates and manually fix them. date = null; } return { ...transaction, date, debit: debit != null ? integerToCurrency(debit) : '', credit: credit != null ? integerToCurrency(credit) : '' }; } function deserializeTransaction(transaction, originalTransaction, dateFormat) { let { debit, credit, date, ...realTransaction } = transaction; let amount; if (debit !== '') { let parsed = evalArithmetic(debit, null); amount = parsed != null ? -parsed : null; } else { amount = evalArithmetic(credit, null); } amount = amount != null ? amountToInteger(amount) : originalTransaction.amount; if (date == null) { date = originalTransaction.date || currentDay(); } return { ...realTransaction, date, amount }; } function getParentTransaction(transactions, fromIndex) { let trans = transactions[fromIndex]; let parent; let parentIdx = fromIndex; while (parentIdx >= 0) { if (transactions[parentIdx].id === trans.parent_id) { // Found the parent return transactions[parentIdx]; } parentIdx--; } return null; } function isLastChild(transactions, index) { let trans = transactions[index]; return ( trans && trans.is_child && (transactions[index + 1] == null || transactions[index + 1].parent_id !== trans.parent_id) ); } let SplitsExpandedContext = React.createContext(null); export function useSplitsExpanded() { let data = useContext(SplitsExpandedContext); return useMemo( () => ({ ...data, expanded: id => data.state.mode === 'collapse' ? !data.state.ids.has(id) : data.state.ids.has(id) }), [data] ); } export function SplitsExpandedProvider({ children, initialMode = 'expand' }) { let cachedState = useSelector(state => state.app.lastSplitState); let reduxDispatch = useDispatch(); let [state, dispatch] = useReducer((state, action) => { switch (action.type) { case 'toggle-split': { let ids = new Set([...state.ids]); let { id } = action; if (ids.has(id)) { ids.delete(id); } else { ids.add(id); } return { ...state, ids }; } case 'open-split': { let ids = new Set([...state.ids]); let { id } = action; if (state.mode === 'collapse') { ids.delete(id); } else { ids.add(id); } return { ...state, ids }; } case 'set-mode': { return { ...state, mode: action.mode, ids: new Set(), transitionId: null }; } case 'switch-mode': if (state.transitionId != null) { // You can only transition once at a time return state; } return { ...state, mode: state.mode === 'expand' ? 'collapse' : 'expand', transitionId: action.id, ids: new Set() }; case 'finish-switch-mode': return { ...state, transitionId: null }; default: throw new Error('Unknown action type: ' + action.type); } }, cachedState.current || { ids: new Set(), mode: initialMode }); useEffect(() => { if (state.transitionId != null) { // This timeout allows animations to finish setTimeout(() => { dispatch({ type: 'finish-switch-mode' }); }, 250); } }, [state.transitionId]); useEffect(() => { // In a finished state, cache the state if (state.transitionId == null) { reduxDispatch({ type: 'SET_LAST_SPLIT_STATE', splitState: state }); } }, [state]); let value = useMemo(() => ({ state, dispatch }), [state, dispatch]); return ( <SplitsExpandedContext.Provider value={value}> {children} </SplitsExpandedContext.Provider> ); } export const TransactionHeader = React.memo( ({ hasSelected, showAccount, showCategory, showBalance }) => { let dispatchSelected = useSelectedDispatch(); return ( <Row borderColor={colors.n9} backgroundColor="white" style={{ color: colors.n4, fontWeight: 300, zIndex: 200 }} > <SelectCell exposed={true} focused={false} selected={hasSelected} width={20} onSelect={() => dispatchSelected({ type: 'select-all' })} /> <Cell value="Date" width={110} /> {showAccount && <Cell value="Account" width="flex" />} <Cell value="Payee" width="flex" /> <Cell value="Notes" width="flex" /> {showCategory && <Cell value="Category" width="flex" />} <Cell value="Payment" width={80} textAlign="right" /> <Cell value="Deposit" width={80} textAlign="right" /> {showBalance && <Cell value="Balance" width={85} textAlign="right" />} <Field width={21} truncate={false} /> <Cell value="" width={15 + styles.scrollbarWidth} /> </Row> ); } ); function getPayeePretty(transaction, payee, transferAcct) { let { payee: payeeId } = transaction; if (transferAcct) { const Icon = transaction.amount > 0 ? LeftArrow2 : RightArrow2; return ( <View style={{ flexDirection: 'row', alignItems: 'center' }} > <Icon width={10} height={8} style={{ marginRight: 5, flexShrink: 0 }} /> <div>{transferAcct.name}</div> </View> ); } else if (payee && !payee.transfer_acct) { // Check to make sure this isn't a transfer because in the rare // occasion that the account has been deleted but the payee is // still there, we don't want to show the name. return payee.name; } else if (payeeId && payeeId.startsWith('new:')) { return payeeId.slice('new:'.length); } return ''; } function StatusCell({ id, focused, selected, status, isChild, onEdit, onUpdate }) { let isClearedField = status === 'cleared' || status == null; let statusProps = getStatusProps(status); let props = { color: status === 'cleared' ? colors.g5 : status === 'missed' ? colors.r6 : status === 'due' ? colors.y5 : selected ? colors.b7 : colors.n7 }; function onSelect() { if (isClearedField) { onUpdate('cleared', !(status === 'cleared')); } } return ( <Cell name="cleared" width="auto" focused={focused} style={{ padding: 1 }} plain > <CellButton style={[ { padding: 3, border: '1px solid transparent', borderRadius: 50, ':focus': { border: '1px solid ' + props.color, boxShadow: `0 1px 2px ${props.color}` }, cursor: isClearedField ? 'pointer' : 'default' }, isChild && { visibility: 'hidden' } ]} onEdit={() => onEdit(id, 'cleared')} onSelect={onSelect} > {React.createElement(statusProps.Icon, { style: { width: 13, height: 13, color: props.color, marginTop: status === 'due' ? -1 : 0 } })} </CellButton> </Cell> ); } function PayeeCell({ id, payeeId, focused, inherited, payees, accounts, valueStyle, transaction, payee, transferAcct, importedPayee, isPreview, onEdit, onUpdate, onCreatePayee, onManagePayees }) { let isCreatingPayee = useRef(false); return ( <CustomCell width="flex" name="payee" value={payeeId} valueStyle={[valueStyle, inherited && { color: colors.n8 }]} formatter={value => getPayeePretty(transaction, payee, transferAcct)} exposed={focused} title={importedPayee || payeeId} onExpose={!isPreview && (name => onEdit(id, name))} onUpdate={async value => { onUpdate('payee', value); if (value && value.startsWith('new:') && !isCreatingPayee.current) { isCreatingPayee.current = true; let id = await onCreatePayee(value.slice('new:'.length)); onUpdate('payee', id); isCreatingPayee.current = false; } }} > {({ onBlur, onKeyDown, onUpdate, onSave, shouldSaveFromKey, inputStyle }) => { return ( <> <PayeeAutocomplete payees={payees} accounts={accounts} value={payeeId} shouldSaveFromKey={shouldSaveFromKey} inputProps={{ onBlur, onKeyDown, style: inputStyle }} showManagePayees={true} tableBehavior={true} defaultFocusTransferPayees={transaction.is_child} focused={true} onUpdate={onUpdate} onSelect={onSave} onManagePayees={() => onManagePayees(payeeId)} /> </> ); }} </CustomCell> ); } function CellWithScheduleIcon({ scheduleId, children }) { let scheduleData = useCachedSchedules(); let schedule = scheduleData.schedules.find(s => s.id === scheduleId); if (schedule == null) { // This must be a deleted schedule return children; } let recurring = schedule._date && !!schedule._date.frequency; let style = { width: 13, height: 13, marginLeft: 5, marginRight: 3, color: 'inherit' }; let Icon = recurring ? ArrowsSynchronize : CalendarIcon; return ( <View style={{ flex: 1, flexDirection: 'row', alignItems: 'stretch' }}> <Cell exposed={true}> {() => recurring ? ( <ArrowsSynchronize style={style} /> ) : ( <CalendarIcon style={{ ...style, transform: 'translateY(-1px)' }} /> ) } </Cell> {children} </View> ); } export const Transaction = React.memo(function Transaction(props) { let { transaction: originalTransaction, editing, backgroundColor = 'white', showAccount, showBalance, showZeroInDeposit, style, hovered, selected, highlighted, added, matched, expanded, inheritedFields, focusedField, categoryGroups, payees, accounts, balance, dateFormat = 'MM/dd/yyyy', onSave, onEdit, onHover, onDelete, onSplit, onManagePayees, onCreatePayee, onToggleSplit } = props; let dispatchSelected = useSelectedDispatch(); let [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit); let [prevTransaction, setPrevTransaction] = useState(originalTransaction); let [transaction, setTransaction] = useState( serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat) ); let isPreview = isPreviewId(transaction.id); if ( originalTransaction !== prevTransaction || showZeroInDeposit !== prevShowZero ) { setTransaction( serializeTransaction(originalTransaction, showZeroInDeposit, dateFormat) ); setPrevTransaction(originalTransaction); setPrevShowZero(showZeroInDeposit); } function onUpdate(name, value) { if (transaction[name] !== value) { let newTransaction = { ...transaction, [name]: value }; if ( name === 'account' && value && getAccountsById(accounts)[value].offbudget ) { newTransaction.category = null; } // If entering an amount in either of the credit/debit fields, we // need to clear out the other one so it's always properly // translated into the desired amount (see // `deserializeTransaction`) if (name === 'credit') { newTransaction['debit'] = ''; } else if (name === 'debit') { newTransaction['credit'] = ''; } // Don't save a temporary value (a new payee) which will be // filled in with a real id later if (name === 'payee' && value && value.startsWith('new:')) { setTransaction(newTransaction); } else { let deserialized = deserializeTransaction( newTransaction, originalTransaction, dateFormat ); // Run the transaction through the formatting so that we know // it's always showing the formatted result setTransaction( serializeTransaction(deserialized, showZeroInDeposit, dateFormat) ); onSave(deserialized); } } } let { id, debit, credit, payee: payeeId, imported_payee: importedPayee, notes, date, account: accountId, category, cleared, is_parent: isParent, _unmatched = false } = transaction; // Join in some data let payee = payees && payeeId && getPayeesById(payees)[payeeId]; let account = accounts && accountId && getAccountsById(accounts)[accountId]; let transferAcct = payee && payee.transfer_acct && getAccountsById(accounts)[payee.transfer_acct]; let isChild = transaction.is_child; let borderColor = selected ? colors.b8 : colors.border; let isBudgetTransfer = transferAcct && transferAcct.offbudget === 0; let isOffBudget = account && account.offbudget === 1; let valueStyle = added ? { fontWeight: 600 } : null; let backgroundFocus = hovered || focusedField === 'select'; return ( <Row borderColor={borderColor} backgroundColor={ selected ? colors.selected : backgroundFocus ? colors.hover : isPreview ? '#fcfcfc' : backgroundColor } highlighted={highlighted} style={[ style, isPreview && { color: colors.n5, fontStyle: 'italic' }, _unmatched && { opacity: 0.5 } ]} onMouseEnter={() => onHover && onHover(transaction.id)} > {isChild && ( <Field borderColor="transparent" width={110} style={{ width: 110, backgroundColor: colors.n11, borderBottomWidth: 0 }} /> )} {isChild && showAccount && ( <Field borderColor="transparent" style={{ flex: 1, backgroundColor: colors.n11, opacity: 0 }} /> )} {isTemporaryId(transaction.id) ? ( isChild ? ( <DeleteCell onDelete={() => onDelete && onDelete(transaction.id)} exposed={hovered || editing} style={[isChild && { borderLeftWidth: 1 }, { lineHeight: 0 }]} /> ) : ( <Cell width={20} /> ) ) : ( <SelectCell exposed={hovered || selected || editing} focused={focusedField === 'select'} onSelect={() => { dispatchSelected({ type: 'select', id: transaction.id }); }} onEdit={() => onEdit(id, 'select')} selected={selected} style={[isChild && { borderLeftWidth: 1 }]} value={ matched && ( <Hyperlink2 style={{ width: 13, height: 13, color: colors.n7 }} /> ) } /> )} {!isChild && ( <CustomCell name="date" width={110} exposed={focusedField === 'date'} value={date} valueStyle={valueStyle} formatter={date => date ? formatDate(parseISO(date), dateFormat) : '' } onExpose={!isPreview && (name => onEdit(id, name))} onUpdate={value => { onUpdate('date', value); }} > {({ onBlur, onKeyDown, onUpdate, onSave, shouldSaveFromKey, inputStyle }) => ( <DateSelect value={date || ''} dateFormat={dateFormat} inputProps={{ onBlur, onKeyDown, style: inputStyle }} shouldSaveFromKey={shouldSaveFromKey} tableBehavior={true} onUpdate={onUpdate} onSelect={onSave} /> )} </CustomCell> )} {!isChild && showAccount && ( <CustomCell name="account" width="flex" value={accountId} formatter={acctId => { let acct = acctId && getAccountsById(accounts)[acctId]; if (acct) { return acct.name; } return ''; }} valueStyle={valueStyle} exposed={focusedField === 'account'} onExpose={!isPreview && (name => onEdit(id, name))} onUpdate={async value => { // Only ever allow non-null values if (value) { onUpdate('account', value); } }} > {({ onBlur, onKeyDown, onUpdate, onSave, shouldSaveFromKey, inputStyle }) => ( <AccountAutocomplete value={accountId} accounts={accounts} shouldSaveFromKey={shouldSaveFromKey} tableBehavior={true} focused={true} inputProps={{ onBlur, onKeyDown, style: inputStyle }} onUpdate={onUpdate} onSelect={onSave} /> )} </CustomCell> )} {(() => { let cell = ( <PayeeCell id={id} payeeId={payeeId} focused={focusedField === 'payee'} inherited={inheritedFields && inheritedFields.has('payee')} payees={payees} accounts={accounts} valueStyle={valueStyle} transaction={transaction} payee={payee} transferAcct={transferAcct} importedPayee={importedPayee} isPreview={isPreview} onEdit={onEdit} onUpdate={onUpdate} onCreatePayee={onCreatePayee} onManagePayees={onManagePayees} /> ); if (transaction.schedule) { return ( <CellWithScheduleIcon scheduleId={transaction.schedule}> {cell} </CellWithScheduleIcon> ); } return cell; })()} {isPreview ? ( <Cell name="notes" width="flex" /> ) : ( <InputCell width="flex" name="notes" exposed={focusedField === 'notes'} focused={focusedField === 'notes'} value={notes || ''} valueStyle={valueStyle} onExpose={!isPreview && (name => onEdit(id, name))} inputProps={{ value: notes || '', onUpdate: onUpdate.bind(null, 'notes') }} /> )} {isPreview ? ( <Cell width="flex" style={{ alignItems: 'flex-start' }} exposed={true}> {() => ( <View style={{ color: notes === 'missed' ? colors.r6 : notes === 'due' ? colors.y4 : selected ? colors.b5 : colors.n6, backgroundColor: notes === 'missed' ? colors.r10 : notes === 'due' ? colors.y9 : selected ? colors.b8 : colors.n10, margin: '0 5px', padding: '3px 7px', borderRadius: 4 }} > {titleFirst(notes)} </View> )} </Cell> ) : isParent ? ( <Cell name="category" width="flex" focused={focusedField === 'category'} style={{ padding: 0 }} plain > <CellButton style={{ alignSelf: 'flex-start', color: colors.n6, borderRadius: 4, transition: 'none', '&:hover': { backgroundColor: 'rgba(100, 100, 100, .15)', color: colors.n5 } }} disabled={isTemporaryId(transaction.id)} onEdit={() => onEdit(id, 'category')} onSelect={() => onToggleSplit(id)} > <View style={{ flexDirection: 'row', alignItems: 'center', alignSelf: 'stretch', borderRadius: 4, flex: 1, padding: 4 }} > {isParent && ( <CheveronDown style={{ width: 14, height: 14, color: 'currentColor', transition: 'transform .08s', transform: expanded ? 'rotateZ(0)' : 'rotateZ(-90deg)' }} /> )} <Text style={{ fontStyle: 'italic', userSelect: 'none' }}> Split </Text> </View> </CellButton> </Cell> ) : isBudgetTransfer || isOffBudget || isPreview ? ( <InputCell name="category" width="flex" exposed={focusedField === 'category'} focused={focusedField === 'category'} onExpose={!isPreview && (name => onEdit(id, name))} value={ isParent ? 'Split' : isOffBudget ? 'Off Budget' : isBudgetTransfer ? 'Transfer' : '' } valueStyle={valueStyle} style={{ fontStyle: 'italic', color: '#c0c0c0', fontWeight: 300 }} inputProps={{ readOnly: true, style: { fontStyle: 'italic' } }} /> ) : ( <CustomCell name="category" width="flex" value={category} formatter={value => value ? getDisplayValue( getCategoriesById(categoryGroups)[value], 'name' ) : transaction.id ? 'Categorize' : '' } exposed={focusedField === 'category'} onExpose={name => onEdit(id, name)} valueStyle={ !category ? { fontStyle: 'italic', fontWeight: 300, color: colors.p8 } : valueStyle } onUpdate={async value => { if (value === 'split') { onSplit(transaction.id); } else { onUpdate('category', value); } }} > {({ onBlur, onKeyDown, onUpdate, onSave, shouldSaveFromKey, inputStyle }) => ( <CategoryAutocomplete categoryGroups={categoryGroups} value={category} focused={true} tableBehavior={true} showSplitOption={!isChild && !isParent} shouldSaveFromKey={shouldSaveFromKey} inputProps={{ onBlur, onKeyDown, style: inputStyle }} onUpdate={onUpdate} onSelect={onSave} /> )} </CustomCell> )} <InputCell type="input" width={80} name="debit" exposed={focusedField === 'debit'} focused={focusedField === 'debit'} value={debit == null ? '' : debit} valueStyle={valueStyle} textAlign="right" title={debit} onExpose={!isPreview && (name => onEdit(id, name))} style={[isParent && { fontStyle: 'italic' }, styles.tnum]} inputProps={{ value: debit, onUpdate: onUpdate.bind(null, 'debit') }} /> <InputCell type="input" width={80} name="credit" exposed={focusedField === 'credit'} focused={focusedField === 'credit'} value={credit == null ? '' : credit} valueStyle={valueStyle} textAlign="right" title={credit} onExpose={!isPreview && (name => onEdit(id, name))} style={[isParent && { fontStyle: 'italic' }, styles.tnum]} inputProps={{ value: credit, onUpdate: onUpdate.bind(null, 'credit') }} /> {showBalance && ( <Cell name="balance" value={ balance == null || isChild || isPreview ? '' : integerToCurrency(balance) } valueStyle={{ color: balance < 0 ? colors.r4 : colors.g4 }} style={styles.tnum} width={85} textAlign="right" /> )} <StatusCell id={id} focused={focusedField === 'cleared'} selected={selected} isPreview={isPreview} status={isPreview ? notes : cleared ? 'cleared' : null} isChild={isChild} onEdit={onEdit} onUpdate={onUpdate} /> <Cell width={15} /> </Row> ); }); export function TransactionError({ error, isDeposit, onAddSplit, style }) { switch (error.type) { case 'SplitTransactionError': if (error.version === 1) { return ( <View style={[ { flexDirection: 'row', alignItems: 'center', padding: '0 5px' }, style ]} data-testid="transaction-error" > <Text> Amount left:{' '} <Text style={{ fontWeight: 500 }}> {integerToCurrency( isDeposit ? error.difference : -error.difference )} </Text> </Text> <View style={{ flex: 1 }} /> <Button style={{ marginLeft: 15, padding: '4px 10px' }} primary onClick={onAddSplit} > Add Split </Button> </View> ); } break; default: return null; } } function makeTemporaryTransactions(currentAccountId, lastDate) { return [ { id: 'temp', date: lastDate || currentDay(), account: currentAccountId || null, cleared: false, amount: 0 } ]; } function isTemporaryId(id) { return id.indexOf('temp') !== -1; } export function isPreviewId(id) { return id.indexOf('preview/') !== -1; } function NewTransaction({ transactions, accounts, currentAccountId, categoryGroups, payees, editingTransaction, hoveredTransaction, focusedField, showAccount, showCategory, showBalance, dateFormat, onHover, onClose, onSplit, onEdit, onDelete, onSave, onAdd, onAddSplit, onManagePayees, onCreatePayee }) { const error = transactions[0].error; const isDeposit = transactions[0].amount > 0; return ( <View style={{ borderBottom: '1px solid #ebebeb', paddingBottom: 6, backgroundColor: 'white' }} data-testid="new-transaction" onKeyDown={e => { if (e.keyCode === 27) { onClose(); } }} onMouseLeave={() => onHover(null)} > {transactions.map((transaction, idx) => ( <Transaction key={transaction.id} editing={editingTransaction === transaction.id} hovered={hoveredTransaction === transaction.id} transaction={transaction} showAccount={showAccount} showCategory={showCategory} showBalance={showBalance} focusedField={editingTransaction === transaction.id && focusedField} showZeroInDeposit={isDeposit} accounts={accounts} categoryGroups={categoryGroups} payees={payees} dateFormat={dateFormat} expanded={true} onHover={onHover} onEdit={onEdit} onSave={onSave} onSplit={onSplit} onDelete={onDelete} onAdd={onAdd} onManagePayees={onManagePayees} onCreatePayee={onCreatePayee} style={{ marginTop: -1 }} /> ))} <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', marginTop: 6, marginRight: 20 }} > <Button style={{ marginRight: 10, padding: '4px 10px' }} onClick={() => onClose()} data-testid="cancel-button" > Cancel </Button> {error ? ( <TransactionError error={error} isDeposit={isDeposit} onAddSplit={() => onAddSplit(transactions[0].id)} /> ) : ( <Button style={{ padding: '4px 10px' }} primary onClick={onAdd} data-testid="add-button" > Add </Button> )} </View> </View> ); } class TransactionTable_ extends React.Component { container = React.createRef(); state = { highlightedRows: null }; componentDidMount() { this.highlight = ids => { this.setState({ highlightedRows: new Set(ids) }, () => { this.setState({ highlightedRows: null }); }); }; } componentWillReceiveProps(nextProps) { const { isAdding } = this.props; if (!isAdding && nextProps.isAdding) { this.props.newNavigator.onEdit('temp', 'date'); } } componentDidUpdate() { this._cachedParent = null; } getParent(trans, index) { let { transactions } = this.props; if (this._cachedParent && this._cachedParent.id === trans.parent_id) { return this._cachedParent; } if (trans.parent_id) { this._cachedParent = getParentTransaction(transactions, index); return this._cachedParent; } return null; } renderRow = ({ item, index, position, editing, focusedFied, onEdit }) => { const { highlightedRows } = this.state; const { transactions, selectedItems, hoveredTransaction, accounts, categoryGroups, payees, showAccount, showCategory, balances, dateFormat = 'MM/dd/yyyy', tableNavigator, isNew, isMatched, isExpanded } = this.props; let trans = item; let hovered = hoveredTransaction === trans.id; let selected = selectedItems.has(trans.id); let highlighted = !selected && (highlightedRows ? highlightedRows.has(trans.id) : false); let parent = this.getParent(trans, index); let isChildDeposit = parent && parent.amount > 0; let expanded = isExpanded && isExpanded((parent || trans).id); // For backwards compatibility, read the error of the transaction // since in previous versions we stored it there. In the future we // can simplify this to just the parent let error = expanded ? (parent && parent.error) || trans.error : trans.error; return ( <> {(!expanded || isLastChild(transactions, index)) && error && error.type === 'SplitTransactionError' && ( <Tooltip position="bottom-right" width={250} forceTop={position} forceLayout={true} style={{ transform: 'translate(-5px, 2px)' }} > <TransactionError error={error} isDeposit={isChildDeposit} onAddSplit={() => this.props.onAddSplit(trans.id)} /> </Tooltip> )} <Transaction editing={editing} transaction={trans} showAccount={showAccount} showCategory={showCategory} showBalance={!!balances} hovered={hovered} selected={selected} highlighted={highlighted} added={isNew && isNew(trans.id)} expanded={isExpanded && isExpanded(trans.id)} matched={isMatched && isMatched(trans.id)} showZeroInDeposit={isChildDeposit} balance={balances && balances[trans.id] && balances[trans.id].balance} focusedField={editing && tableNavigator.focusedField} accounts={accounts} categoryGroups={categoryGroups} payees={payees} inheritedFields={ parent && parent.payee === trans.payee ? new Set(['payee']) : new Set() } dateFormat={dateFormat} onHover={this.props.onHover} onEdit={tableNavigator.onEdit} onSave={this.props.onSave} onDelete={this.props.onDelete} onSplit={this.props.onSplit} onManagePayees={this.props.onManagePayees} onCreatePayee={this.props.onCreatePayee} onToggleSplit={this.props.onToggleSplit} /> </> ); }; render() { let { props } = this; let { tableNavigator, tableRef, dateFormat = 'MM/dd/yyyy', newNavigator, renderEmpty, onHover, onScroll } = props; return ( <View innerRef={this.container} style={[{ flex: 1, cursor: 'default' }, props.style]} > <View> <TransactionHeader hasSelected={props.selectedItems.size > 0} showAccount={props.showAccount} showCategory={props.showCategory} showBalance={!!props.balances} /> {props.isAdding && ( <View {...newNavigator.getNavigatorProps({ onKeyDown: e => props.onCheckNewEnter(e) })} > <NewTransaction transactions={props.newTransactions} editingTransaction={newNavigator.editingId} hoveredTransaction={props.hoveredTransaction} focusedField={newNavigator.focusedField} accounts={props.accounts} currentAccountId={props.currentAccountId} categoryGroups={props.categoryGroups} payees={this.props.payees || []} showAccount={props.showAccount} showCategory={props.showCategory} showBalance={!!props.balances} dateFormat={dateFormat} onClose={props.onCloseAddTransaction} onAdd={this.props.onAddTemporary} onAddSplit={this.props.onAddSplit} onSplit={this.props.onSplit} onEdit={newNavigator.onEdit} onSave={this.props.onSave} onDelete={this.props.onDelete} onHover={this.props.onHover} onManagePayees={this.props.onManagePayees} onCreatePayee={this.props.onCreatePayee} /> </View> )} </View> {/*// * On Windows, makes the scrollbar always appear // the full height of the container ??? */} <View style={[{ flex: 1, overflow: 'hidden' }]} data-testid="transaction-table" onMouseLeave={() => onHover(null)} > <Table navigator={tableNavigator} ref={tableRef} items={props.transactions} renderItem={this.renderRow} renderEmpty={renderEmpty} loadMore={props.loadMoreTransactions} isSelected={id => props.selectedItems.has(id)} onKeyDown={e => props.onCheckEnter(e)} onScroll={onScroll} /> {props.isAdding && ( <div key="shadow" style={{ position: 'absolute', top: -20, left: 0, right: 0, height: 20, backgroundColor: 'red', boxShadow: '0 0 6px rgba(0, 0, 0, .20)' }} /> )} </View> </View> ); } } export let TransactionTable = React.forwardRef((props, ref) => { let [newTransactions, setNewTransactions] = useState(null); let [hoveredTransaction, setHoveredTransaction] = useState( props.hoveredTransaction ); let [prevIsAdding, setPrevIsAdding] = useState(false); let splitsExpanded = useSplitsExpanded(); let prevSplitsExpanded = useRef(null); let tableRef = useRef(null); let mergedRef = useMergedRefs(tableRef, ref); let transactions = useMemo(() => { let result; if (splitsExpanded.state.transitionId != null) { let index = props.transactions.findIndex( t => t.id === splitsExpanded.state.transitionId ); result = props.transactions.filter((t, idx) => { if (t.parent_id) { if (idx >= index) { return splitsExpanded.expanded(t.parent_id); } else if (prevSplitsExpanded.current) { return prevSplitsExpanded.current.expanded(t.parent_id); } } return true; }); } else { if ( prevSplitsExpanded.current && prevSplitsExpanded.current.state.transitionId != null ) { tableRef.current.anchor(); tableRef.current.setRowAnimation(false); } prevSplitsExpanded.current = splitsExpanded; result = props.transactions.filter(t => { if (t.parent_id) { return splitsExpanded.expanded(t.parent_id); } return true; }); } prevSplitsExpanded.current = splitsExpanded; return result; }, [props.transactions, splitsExpanded]); useEffect(() => { // If it's anchored that means we've also disabled animations. To // reduce the chance for side effect collision, only do this if // we've actually anchored it if (tableRef.current.isAnchored()) { tableRef.current.unanchor(); tableRef.current.setRowAnimation(true); } }, [prevSplitsExpanded.current]); let newNavigator = useTableNavigator(newTransactions, getFields); let tableNavigator = useTableNavigator(transactions, getFields); let shouldAdd = useRef(false); let latestState = useRef({ newTransactions, newNavigator, tableNavigator }); let savePending = useRef(false); let afterSaveFunc = useRef(false); // eslint-disable-next-line let [_, forceRerender] = useState({}); let selectedItems = useSelectedItems(); let dispatchSelected = useSelectedDispatch(); useLayoutEffect(() => { latestState.current = { newTransactions, newNavigator, tableNavigator, transactions: props.transactions }; }); // Derive new transactions from the `isAdding` prop if (prevIsAdding !== props.isAdding) { if (!prevIsAdding && props.isAdding) { setNewTransactions(makeTemporaryTransactions(props.currentAccountId)); } setPrevIsAdding(props.isAdding); } useEffect(() => { if (shouldAdd.current) { if (newTransactions[0].account == null) { props.addNotification({ type: 'error', message: 'Account is a required field' }); newNavigator.onEdit('temp', 'account'); } else { let transactions = latestState.current.newTransactions; let lastDate = transactions.length > 0 ? transactions[0].date : null; setNewTransactions( makeTemporaryTransactions(props.currentAccountId, lastDate) ); newNavigator.onEdit('temp', 'date'); props.onAdd(transactions); } shouldAdd.current = false; } }); useEffect(() => { if (savePending.current && afterSaveFunc.current) { afterSaveFunc.current(props); afterSaveFunc.current = null; } savePending.current = false; }, [newTransactions, props.transactions]); function getFields(item) { let fields = [ 'select', 'date', 'account', 'payee', 'notes', 'category', 'debit', 'credit', 'cleared' ]; fields = item.is_child ? ['select', 'payee', 'notes', 'category', 'debit', 'credit'] : fields.filter( f => (props.showAccount || f !== 'account') && (props.showCategory || f !== 'category') ); if (isPreviewId(item.id)) { fields = ['select', 'cleared']; } if (isTemporaryId(item.id)) { // You can't focus the select/delete button of temporary // transactions fields = fields.slice(1); } return fields; } function afterSave(func) { if (savePending.current) { afterSaveFunc.current = func; } else { func(props); } } function onCheckNewEnter(e) { const ENTER = 13; if (e.keyCode === ENTER) { if (e.metaKey) { e.stopPropagation(); onAddTemporary(); } else if (!e.shiftKey) { function getLastTransaction(state) { let { newTransactions } = state.current; return newTransactions[newTransactions.length - 1]; } // Right now, the table navigator does some funky stuff with // focus, so we want to stop it from handling this event. We // still want enter to move up/down normally, so we only stop // it if we are on the last transaction (where we are about to // do some logic). I don't like this. if (newNavigator.editingId === getLastTransaction(latestState).id) { e.stopPropagation(); } afterSave(() => { let lastTransaction = getLastTransaction(latestState); let isSplit = lastTransaction.parent_id || lastTransaction.is_parent; if ( latestState.current.newTransactions[0].error && newNavigator.editingId === lastTransaction.id ) { // add split onAddSplit(lastTransaction.id); } else if ( (newNavigator.focusedField === 'debit' || newNavigator.focusedField === 'credit' || newNavigator.focusedField === 'cleared') && newNavigator.editingId === lastTransaction.id && (!isSplit || !lastTransaction.error) ) { onAddTemporary(); } }); } } } function onCheckEnter(e) { const ENTER = 13; if (e.keyCode === ENTER && !e.shiftKey) { let { editingId: id, focusedField } = tableNavigator; afterSave(props => { let transactions = latestState.current.transactions; let idx = transactions.findIndex(t => t.id === id); let parent = getParentTransaction(transactions, idx); if ( isLastChild(transactions, idx) && parent && parent.error && focusedField !== 'select' ) { e.stopPropagation(); onAddSplit(id); } }); } } let onAddTemporary = useCallback(() => { shouldAdd.current = true; // A little hacky - this forces a rerender which will cause the // effect we want to run. We have to wait for all updates to be // committed (the input could still be saving a value). forceRerender({}); }, [props.onAdd, newNavigator.onEdit]); let onSave = useCallback( async transaction => { savePending.current = true; if (isTemporaryId(transaction.id)) { if (props.onApplyRules) { transaction = await props.onApplyRules(transaction); } let newTrans = latestState.current.newTransactions; setNewTransactions(updateTransaction(newTrans, transaction).data); } else { props.onSave(transaction); } }, [props.onSave] ); let onHover = useCallback(id => { setHoveredTransaction(id); }, []); let onDelete = useCallback(id => { let temporary = isTemporaryId(id); if (temporary) { let newTrans = latestState.current.newTransactions; if (id === newTrans[0].id) { // You can never delete the parent new transaction return; } setNewTransactions(deleteTransaction(newTrans, id).data); } }, []); let onSplit = useMemo(() => { return id => { if (isTemporaryId(id)) { let { newNavigator } = latestState.current; let newTrans = latestState.current.newTransactions; let { data, diff } = splitTransaction(newTrans, id); setNewTransactions(data); // TODO: what is this for??? // if (newTrans[0].amount == null) { // newNavigator.onEdit(newTrans[0].id, 'debit'); // } else { newNavigator.onEdit( diff.added[0].id, latestState.current.newNavigator.focusedField ); // } } else { let trans = latestState.current.transactions.find(t => t.id === id); let newId = props.onSplit(id); splitsExpanded.dispatch({ type: 'open-split', id: trans.id }); let { tableNavigator } = latestState.current; if (trans.amount == null) { tableNavigator.onEdit(trans.id, 'debit'); } else { tableNavigator.onEdit(newId, tableNavigator.focusedField); } } }; }, [props.onSplit, splitsExpanded.dispatch]); let onAddSplit = useCallback( id => { if (isTemporaryId(id)) { let newTrans = latestState.current.newTransactions; let { data, diff } = addSplitTransaction(newTrans, id); setNewTransactions(data); newNavigator.onEdit( diff.added[0].id, latestState.current.newNavigator.focusedField ); } else { let newId = props.onAddSplit(id); tableNavigator.onEdit( newId, latestState.current.tableNavigator.focusedField ); } }, [props.onAddSplit] ); function onCloseAddTransaction() { setNewTransactions(makeTemporaryTransactions(props.currentAccountId)); props.onCloseAddTransaction(); } let onToggleSplit = useCallback( id => splitsExpanded.dispatch({ type: 'toggle-split', id }), [splitsExpanded.dispatch] ); return ( // eslint-disable-next-line <TransactionTable_ tableRef={mergedRef} {...props} transactions={transactions} selectedItems={selectedItems} hoveredTransaction={hoveredTransaction} isExpanded={splitsExpanded.expanded} onHover={onHover} onSave={onSave} onDelete={onDelete} onSplit={onSplit} onCheckNewEnter={onCheckNewEnter} onCheckEnter={onCheckEnter} onAddTemporary={onAddTemporary} onAddSplit={onAddSplit} onCloseAddTransaction={onCloseAddTransaction} onToggleSplit={onToggleSplit} newTransactions={newTransactions} tableNavigator={tableNavigator} newNavigator={newNavigator} /> ); });