diff --git a/.eslintrc.js b/.eslintrc.js index 8796c108593..0c2caca16c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -162,7 +162,7 @@ module.exports = { // Rules disable during TS migration '@typescript-eslint/no-var-requires': 'off', - 'prefer-const': 'off', + 'prefer-const': 'warn', 'prefer-spread': 'off', '@typescript-eslint/no-empty-function': 'off', }, @@ -238,54 +238,6 @@ module.exports = { 'no-restricted-imports': ['off', { patterns: restrictedImportColors }], }, }, - // TODO: Remove this override once we addressed all warnings and enable the rule globally. - { - files: [ - './packages/api/*', - './packages/api/app/**/*', - './packages/crdt/**/*', - './packages/desktop-client/src/*', - './packages/desktop-client/src/components/*', - './packages/desktop-client/src/components/accounts/**/*', - './packages/desktop-client/src/components/autocomplete/**/*', - './packages/desktop-client/src/components/budget/**/*', - './packages/desktop-client/src/components/common/**/*', - './packages/desktop-client/src/components/filters/**/*', - './packages/desktop-client/src/components/gocardless/**/*', - './packages/desktop-client/src/components/manager/**/*', - './packages/desktop-client/src/components/mobile/**/*', - './packages/desktop-client/src/components/modals/**/*', - './packages/desktop-client/src/components/payees/**/*', - './packages/desktop-client/src/components/reports/**/*', - './packages/desktop-client/src/components/responsive/**/*', - './packages/desktop-client/src/components/rules/**/*', - './packages/desktop-client/src/components/schedules/**/*', - './packages/desktop-client/src/components/select/**/*', - './packages/desktop-client/src/components/settings/**/*', - './packages/desktop-client/src/components/sidebar/**/*', - './packages/desktop-client/src/components/spreadsheet/**/*', - './packages/desktop-client/src/components/transactions/**/*', - './packages/desktop-client/src/components/util/**/*', - './packages/desktop-client/src/hooks/**/*', - './packages/desktop-client/src/icons/**/*', - './packages/desktop-client/src/style/**/*', - './packages/desktop-client/src/types/**/*', - './packages/desktop-client/src/util/**/*', - './packages/desktop-electron/**/*', - './packages/eslint-plugin-actual/**/*', - './packages/loot-core/*', - './packages/loot-core/src/client/**/*', - './packages/loot-core/src/mocks/**/*', - './packages/loot-core/src/platform/**/*', - './packages/loot-core/src/server/**/*', - './packages/loot-core/src/shared/**/*', - './packages/loot-core/src/types/**/*', - './packages/loot-core/webpack/**/*', - ], - rules: { - 'prefer-const': 'warn', - }, - }, ], settings: { 'import/resolver': { diff --git a/packages/desktop-client/e2e/budget.test.js b/packages/desktop-client/e2e/budget.test.js index 95404837f5b..630d6d87a76 100644 --- a/packages/desktop-client/e2e/budget.test.js +++ b/packages/desktop-client/e2e/budget.test.js @@ -59,8 +59,8 @@ test.describe('Budget', () => { }); test('clicking on spent amounts opens a transaction page', async () => { - let categoryName = await budgetPage.getCategoryNameForRow(1); - let accountPage = await budgetPage.clickOnSpentAmountForRow(1); + const categoryName = await budgetPage.getCategoryNameForRow(1); + const accountPage = await budgetPage.clickOnSpentAmountForRow(1); expect(page.url()).toContain('/accounts'); expect(await accountPage.accountName.textContent()).toMatch( new RegExp(String.raw`${categoryName} \(\w+ \d+\)`), diff --git a/packages/desktop-client/e2e/mobile.test.js b/packages/desktop-client/e2e/mobile.test.js index fa53ecd5152..b67a03e4243 100644 --- a/packages/desktop-client/e2e/mobile.test.js +++ b/packages/desktop-client/e2e/mobile.test.js @@ -83,6 +83,8 @@ test.describe('Mobile', () => { await expect(transactionEntryPage.header).toHaveText('New Transaction'); await transactionEntryPage.amountField.fill('12.34'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); await transactionEntryPage.fillField( page.getByTestId('payee-field'), 'Kroger', @@ -114,6 +116,8 @@ test.describe('Mobile', () => { await expect(page).toMatchThemeScreenshots(); await transactionEntryPage.amountField.fill('12.34'); + // Click anywhere to cancel active edit. + await transactionEntryPage.header.click(); await transactionEntryPage.fillField( page.getByTestId('payee-field'), 'Kroger', diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png index 109397af4d8..71c34be4250 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png index d0990083a99..885ad0401f9 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-from-accounts-id-page-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png index 98d8921bfd4..f66759c3186 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png index ff9a2444f99..3f118cdc0ac 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png index 27df8c3e591..3404a4f5b32 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-3-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png index ff627b5a0a6..6bb639fa3de 100644 Binary files a/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png and b/packages/desktop-client/e2e/mobile.test.js-snapshots/Mobile-creates-a-transaction-via-footer-button-4-chromium-linux.png differ diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 6c9238faf2a..5da4049a2bb 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -9,6 +9,8 @@ import useCategories from '../hooks/useCategories'; import useSyncServerStatus from '../hooks/useSyncServerStatus'; import { type CommonModalProps } from '../types/modals'; +import CategoryGroupMenu from './modals/CategoryGroupMenu'; +import CategoryMenu from './modals/CategoryMenu'; import CloseAccount from './modals/CloseAccount'; import ConfirmCategoryDelete from './modals/ConfirmCategoryDelete'; import ConfirmTransactionEdit from './modals/ConfirmTransactionEdit'; @@ -24,6 +26,7 @@ import ImportTransactions from './modals/ImportTransactions'; import LoadBackup from './modals/LoadBackup'; import ManageRulesModal from './modals/ManageRulesModal'; import MergeUnusedPayees from './modals/MergeUnusedPayees'; +import Notes from './modals/Notes'; import PlaidExternalMsg from './modals/PlaidExternalMsg'; import ReportBudgetSummary from './modals/ReportBudgetSummary'; import RolloverBudgetSummary from './modals/RolloverBudgetSummary'; @@ -232,6 +235,7 @@ export default function Modals() { modalProps={modalProps} name={options.name} onSubmit={options.onSubmit} + onClose={options.onClose} /> ); @@ -240,7 +244,7 @@ export default function Modals() { ); + case 'category-menu': + return ( + + ); + + case 'category-group-menu': + return ( + + ); + + case 'notes': + return ( + + ); + default: console.error('Unknown modal:', name); return null; diff --git a/packages/desktop-client/src/components/Notes.tsx b/packages/desktop-client/src/components/Notes.tsx new file mode 100644 index 00000000000..818d77a0188 --- /dev/null +++ b/packages/desktop-client/src/components/Notes.tsx @@ -0,0 +1,133 @@ +import React, { useEffect, useRef } from 'react'; +import ReactMarkdown from 'react-markdown'; + +import { css } from 'glamor'; +import remarkGfm from 'remark-gfm'; + +import { useResponsive } from '../ResponsiveProvider'; +import { type CSSProperties, theme } from '../style'; +import { remarkBreaks, sequentialNewlinesPlugin } from '../util/markdown'; + +import Text from './common/Text'; + +const remarkPlugins = [sequentialNewlinesPlugin, remarkGfm, remarkBreaks]; + +const markdownStyles = css({ + display: 'block', + maxWidth: 350, + padding: 8, + overflowWrap: 'break-word', + '& p': { + margin: 0, + ':not(:first-child)': { + marginTop: '0.25rem', + }, + }, + '& ul, & ol': { + listStylePosition: 'inside', + margin: 0, + paddingLeft: 0, + }, + '&>* ul, &>* ol': { + marginLeft: '1.5rem', + }, + '& li>p': { + display: 'contents', + }, + '& blockquote': { + paddingLeft: '0.75rem', + borderLeft: '3px solid ' + theme.markdownDark, + margin: 0, + }, + '& hr': { + borderTop: 'none', + borderLeft: 'none', + borderRight: 'none', + borderBottom: '1px solid ' + theme.markdownNormal, + }, + '& code': { + backgroundColor: theme.markdownLight, + padding: '0.1rem 0.5rem', + borderRadius: '0.25rem', + }, + '& pre': { + padding: '0.5rem', + backgroundColor: theme.markdownLight, + borderRadius: '0.5rem', + margin: 0, + ':not(:first-child)': { + marginTop: '0.25rem', + }, + '& code': { + background: 'inherit', + padding: 0, + borderRadius: 0, + }, + }, + '& table, & th, & td': { + border: '1px solid ' + theme.markdownNormal, + }, + '& table': { + borderCollapse: 'collapse', + wordBreak: 'break-word', + }, + '& td': { + padding: '0.25rem 0.75rem', + }, +}); + +type NotesProps = { + notes: string; + editable?: boolean; + focused?: boolean; + onChange?: (value: string) => void; + onBlur?: (value: string) => void; + getStyle?: (editable: boolean) => CSSProperties; +}; + +export default function Notes({ + notes, + editable, + focused, + onChange, + onBlur, + getStyle, +}: NotesProps) { + const { isNarrowWidth } = useResponsive(); + const _onChange = value => { + onChange?.(value); + }; + + const textAreaRef = useRef(); + + useEffect(() => { + if (focused && editable) { + textAreaRef.current.focus(); + } + }, [focused, editable]); + + return editable ? ( + _onChange(e.target.value)} + onBlur={e => onBlur?.(e.target.value)} + placeholder="Notes (markdown supported)" + /> + ) : ( + + + {notes} + + + ); +} diff --git a/packages/desktop-client/src/components/NotesButton.tsx b/packages/desktop-client/src/components/NotesButton.tsx index bbb1e14b67e..3e43538993e 100644 --- a/packages/desktop-client/src/components/NotesButton.tsx +++ b/packages/desktop-client/src/components/NotesButton.tsx @@ -1,8 +1,4 @@ -import React, { createRef, useState, useEffect } from 'react'; -import ReactMarkdown from 'react-markdown'; - -import { css } from 'glamor'; -import remarkGfm from 'remark-gfm'; +import React, { useState } from 'react'; import q from 'loot-core/src/client/query-helpers'; import { useLiveQuery } from 'loot-core/src/client/query-hooks'; @@ -10,79 +6,12 @@ import { send } from 'loot-core/src/platform/client/fetch'; import CustomNotesPaper from '../icons/v2/CustomNotesPaper'; import { type CSSProperties, theme } from '../style'; -import { remarkBreaks, sequentialNewlinesPlugin } from '../util/markdown'; import Button from './common/Button'; -import Text from './common/Text'; import View from './common/View'; +import Notes from './Notes'; import { Tooltip, type TooltipPosition, useTooltip } from './tooltips'; -const remarkPlugins = [sequentialNewlinesPlugin, remarkGfm, remarkBreaks]; - -const markdownStyles = css({ - display: 'block', - maxWidth: 350, - padding: 8, - overflowWrap: 'break-word', - '& p': { - margin: 0, - ':not(:first-child)': { - marginTop: '0.25rem', - }, - }, - '& ul, & ol': { - listStylePosition: 'inside', - margin: 0, - paddingLeft: 0, - }, - '&>* ul, &>* ol': { - marginLeft: '1.5rem', - }, - '& li>p': { - display: 'contents', - }, - '& blockquote': { - paddingLeft: '0.75rem', - borderLeft: '3px solid ' + theme.markdownDark, - margin: 0, - }, - '& hr': { - borderTop: 'none', - borderLeft: 'none', - borderRight: 'none', - borderBottom: '1px solid ' + theme.markdownNormal, - }, - '& code': { - backgroundColor: theme.markdownLight, - padding: '0.1rem 0.5rem', - borderRadius: '0.25rem', - }, - '& pre': { - padding: '0.5rem', - backgroundColor: theme.markdownLight, - borderRadius: '0.5rem', - margin: 0, - ':not(:first-child)': { - marginTop: '0.25rem', - }, - '& code': { - background: 'inherit', - padding: 0, - borderRadius: 0, - }, - }, - '& table, & th, & td': { - border: '1px solid ' + theme.markdownNormal, - }, - '& table': { - borderCollapse: 'collapse', - wordBreak: 'break-word', - }, - '& td': { - padding: '0.25rem 0.75rem', - }, -}); - type NotesTooltipProps = { editable?: boolean; defaultNotes?: string; @@ -96,39 +25,14 @@ function NotesTooltip({ onClose, }: NotesTooltipProps) { const [notes, setNotes] = useState(defaultNotes); - const inputRef = createRef(); - - useEffect(() => { - if (editable) { - inputRef.current.focus(); - } - }, [inputRef, editable]); - return ( onClose(notes)}> - {editable ? ( - setNotes(e.target.value)} - placeholder="Notes (markdown supported)" - /> - ) : ( - - - {notes} - - - )} + ); } diff --git a/packages/desktop-client/src/components/budget/MobileBudget.jsx b/packages/desktop-client/src/components/budget/MobileBudget.jsx index 8c3e7327c65..0408a57560d 100644 --- a/packages/desktop-client/src/components/budget/MobileBudget.jsx +++ b/packages/desktop-client/src/components/budget/MobileBudget.jsx @@ -3,16 +3,6 @@ import { useSelector } from 'react-redux'; import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider'; import { send, listen } from 'loot-core/src/platform/client/fetch'; -import { - addCategory, - addGroup, - deleteCategory, - deleteGroup, - moveCategory, - moveCategoryGroup, - updateCategory, - updateGroup, -} from 'loot-core/src/shared/categories'; import * as monthUtils from 'loot-core/src/shared/months'; import { useActions } from '../../hooks/useActions'; @@ -26,6 +16,9 @@ import SyncRefresh from '../SyncRefresh'; import { BudgetTable } from './MobileBudgetTable'; import { prewarmMonth, switchBudgetType } from './util'; +const CATEGORY_BUDGET_EDIT_ACTION = 'category-budget'; +const BALANCE_MENU_OPEN_ACTION = 'balance-menu'; + class Budget extends Component { constructor(props) { super(props); @@ -36,13 +29,13 @@ class Budget extends Component { currentMonth, initialized: false, editMode: false, - categoryGroups: [], + editingBudgetCategoryId: null, + openBalanceActionMenuId: null, }; } async loadCategories() { - const result = await this.props.getCategories(); - this.setState({ categoryGroups: result.grouped }); + await this.props.getCategories(); } async componentDidMount() { @@ -50,18 +43,17 @@ class Budget extends Component { // this.setState({ editMode: false }); // }); - this.loadCategories(); - const { start, end } = await send('get-budget-bounds'); - this.setState({ bounds: { start, end } }); - await prewarmMonth( this.props.budgetType, this.props.spreadsheet, this.state.currentMonth, ); - this.setState({ initialized: true }); + this.setState({ + bounds: { start, end }, + initialized: true, + }); const unlisten = listen('sync-event', ({ type, tables }) => { if ( @@ -107,15 +99,7 @@ class Budget extends Component { this.props.pushModal('new-category-group', { onValidate: name => (!name ? 'Name is required.' : null), onSubmit: async name => { - const id = await this.props.createGroup(name); - this.setState(state => ({ - categoryGroups: addGroup(state.categoryGroups, { - id, - name, - categories: [], - is_income: 0, - }), - })); + await this.props.createGroup(name); }, }); }; @@ -124,28 +108,19 @@ class Budget extends Component { this.props.pushModal('new-category', { onValidate: name => (!name ? 'Name is required.' : null), onSubmit: async name => { - const id = await this.props.createCategory(name, groupId, isIncome); - this.setState(state => ({ - categoryGroups: addCategory(state.categoryGroups, { - id, - name, - cat_group: groupId, - is_income: isIncome ? 1 : 0, - }), - })); + this.props.collapseModals('category-group-menu'); + await this.props.createCategory(name, groupId, isIncome); }, }); }; onSaveGroup = group => { this.props.updateGroup(group); - this.setState(state => ({ - categoryGroups: updateGroup(state.categoryGroups, group), - })); }; onDeleteGroup = async groupId => { - const group = this.state.categoryGroups?.find(g => g.id === groupId); + const { categoryGroups } = this.props; + const group = categoryGroups?.find(g => g.id === groupId); if (!group) { return; @@ -163,25 +138,18 @@ class Budget extends Component { this.props.pushModal('confirm-category-delete', { group: groupId, onDelete: transferCategory => { + this.props.collapseModals('category-group-menu'); this.props.deleteGroup(groupId, transferCategory); - this.setState(state => ({ - categoryGroups: deleteGroup(state.categoryGroups, groupId), - })); }, }); } else { + this.props.collapseModals('category-group-menu'); this.props.deleteGroup(groupId); - this.setState(state => ({ - categoryGroups: deleteGroup(state.categoryGroups, groupId), - })); } }; onSaveCategory = category => { this.props.updateCategory(category); - this.setState(state => ({ - categoryGroups: updateCategory(state.categoryGroups, category), - })); }; onDeleteCategory = async categoryId => { @@ -194,23 +162,19 @@ class Budget extends Component { category: categoryId, onDelete: transferCategory => { if (categoryId !== transferCategory) { + this.props.collapseModals('category-menu'); this.props.deleteCategory(categoryId, transferCategory); - this.setState(state => ({ - categoryGroups: deleteCategory(state.categoryGroups, categoryId), - })); } }, }); } else { + this.props.collapseModals('category-menu'); this.props.deleteCategory(categoryId); - this.setState(state => ({ - categoryGroups: deleteCategory(state.categoryGroups, categoryId), - })); } }; onReorderCategory = (id, { inGroup, aroundCategory }) => { - const { categoryGroups } = this.state; + const { categoryGroups } = this.props; let groupId, targetId; if (inGroup) { @@ -234,14 +198,10 @@ class Budget extends Component { } this.props.moveCategory(id, groupId, targetId); - - this.setState({ - categoryGroups: moveCategory(categoryGroups, id, groupId, targetId), - }); }; onReorderGroup = (id, targetId, position) => { - const { categoryGroups } = this.state; + const { categoryGroups } = this.props; if (position === 'bottom') { const idx = categoryGroups.findIndex(group => group.id === targetId); @@ -250,10 +210,6 @@ class Budget extends Component { } this.props.moveCategoryGroup(id, targetId); - - this.setState({ - categoryGroups: moveCategoryGroup(categoryGroups, id, targetId), - }); }; sync = async () => { @@ -280,16 +236,14 @@ class Budget extends Component { this.setState({ currentMonth: month, initialized: true }); }; - onOpenActionSheet = () => { + onOpenMonthActionMenu = () => { const { budgetType } = this.props; const options = [ - 'Edit Categories', 'Copy last month’s budget', 'Set budgets to zero', 'Set budgets to 3 month average', budgetType === 'report' && 'Apply to all future budgets', - 'Cancel', ].filter(Boolean); this.props.showActionSheetWithOptions( @@ -341,11 +295,90 @@ class Budget extends Component { this.setState({ initialized: true }); }; + onSaveNotes = async (id, notes) => { + await send('notes-save', { id, note: notes }); + }; + + onEditGroupNotes = id => { + const { categoryGroups } = this.props; + const group = categoryGroups.find(g => g.id === id); + this.props.pushModal('notes', { + id, + name: group.name, + onSave: this.onSaveNotes, + }); + }; + + onEditCategoryNotes = id => { + const { categories } = this.props; + const category = categories.find(c => c.id === id); + this.props.pushModal('notes', { + id, + name: category.name, + onSave: this.onSaveNotes, + }); + }; + + onEditGroup = id => { + const { categoryGroups } = this.props; + const group = categoryGroups.find(g => g.id === id); + this.props.pushModal('category-group-menu', { + groupId: group.id, + onSave: this.onSaveGroup, + onAddCategory: this.onAddCategory, + onEditNotes: this.onEditGroupNotes, + onDelete: this.onDeleteGroup, + }); + }; + + onEditCategory = id => { + const { categories } = this.props; + const category = categories.find(c => c.id === id); + this.props.pushModal('category-menu', { + categoryId: category.id, + onSave: this.onSaveCategory, + onEditNotes: this.onEditCategoryNotes, + onDelete: this.onDeleteCategory, + }); + }; + + onEditCategoryBudget = id => { + this.onEdit(CATEGORY_BUDGET_EDIT_ACTION, id); + }; + + onOpenBalanceActionMenu = id => { + this.onEdit(BALANCE_MENU_OPEN_ACTION, id); + }; + + onEdit = (action, id) => { + const { editingBudgetCategoryId, openBalanceActionMenuId } = this.state; + + // Do not allow editing if another field is currently being edited. + // Cancel the currently editing field in that case. + const currentlyEditing = editingBudgetCategoryId || openBalanceActionMenuId; + + this.setState({ + editingBudgetCategoryId: + action === CATEGORY_BUDGET_EDIT_ACTION && !currentlyEditing ? id : null, + openBalanceActionMenuId: + action === BALANCE_MENU_OPEN_ACTION && !currentlyEditing ? id : null, + }); + + return { action, editingId: !currentlyEditing ? id : null }; + }; + render() { - const { currentMonth, bounds, editMode, initialized } = this.state; const { - categories, + currentMonth, + bounds, + editMode, + initialized, + editingBudgetCategoryId, + openBalanceActionMenuId, + } = this.state; + const { categoryGroups, + categories, prefs, savePrefs, budgetType, @@ -379,8 +412,8 @@ class Budget extends Component { // This key forces the whole table rerender when the number // format changes key={numberFormat + hideFraction} - categories={categories} categoryGroups={categoryGroups} + categories={categories} type={budgetType} month={currentMonth} monthBounds={bounds} @@ -401,12 +434,21 @@ class Budget extends Component { onDeleteCategory={this.onDeleteCategory} onReorderCategory={this.onReorderCategory} onReorderGroup={this.onReorderGroup} - onOpenActionSheet={() => {}} //this.onOpenActionSheet} + onOpenMonthActionMenu={this.onOpenMonthActionMenu} onBudgetAction={applyBudgetAction} onRefresh={onRefresh} onSwitchBudgetType={this.onSwitchBudgetType} + onSaveNotes={this.onSaveNotes} + onEditGroupNotes={this.onEditGroupNotes} + onEditCategoryNotes={this.onEditCategoryNotes} savePrefs={savePrefs} pushModal={pushModal} + onEditGroup={this.onEditGroup} + onEditCategory={this.onEditCategory} + editingBudgetCategoryId={editingBudgetCategoryId} + onEditCategoryBudget={this.onEditCategoryBudget} + openBalanceActionMenuId={openBalanceActionMenuId} + onOpenBalanceActionMenu={this.onOpenBalanceActionMenu} /> )} diff --git a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx b/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx index 4f8e81107c1..d326e87ce3a 100644 --- a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx +++ b/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx @@ -14,7 +14,6 @@ import { useResponsive } from '../../ResponsiveProvider'; import { theme, styles } from '../../style'; import Button from '../common/Button'; import Card from '../common/Card'; -import InputWithContent from '../common/InputWithContent'; import Label from '../common/Label'; import Menu from '../common/Menu'; import Text from '../common/Text'; @@ -151,7 +150,7 @@ function BudgetCell({ return ( onEdit?.(null)} /> { - if (isBudgetActionMenuOpen) { + if (isBalanceActionMenuOpen) { balanceTooltip.open(); } - }, [isBudgetActionMenuOpen, balanceTooltip]); - - useEffect(() => { - if (!isEditing && tooltip.isOpen) { - tooltip.close(); - } - }, [isEditing, tooltip]); - - const onSubmit = () => { - if (categoryName) { - onSave?.({ - ...category, - name: categoryName, - }); - } else { - setCategoryName(category.name); - } - onEdit?.(null); - }; - - const onMenuSelect = type => { - onEdit?.(null); - switch (type) { - case 'toggle-visibility': - setIsHidden(!isHidden); - onSave?.({ - ...category, - hidden: !isHidden, - }); - break; - case 'delete': - onDelete?.(category.id); - break; - default: - throw new Error(`Unrecognized category menu type: ${type}`); - } - }; + }, [isBalanceActionMenuOpen, balanceTooltip]); const listItemRef = useRef(); - const inputRef = useRef(); const _onBudgetAction = (monthIndex, action, arg) => { onBudgetAction?.( @@ -320,90 +273,23 @@ const ExpenseCategory = memo(function ExpenseCategory({ const content = ( 0 ? 1 : 0, - opacity: isHidden ? 0.5 : undefined, + opacity: !!category.hidden ? 0.5 : undefined, ...style, }} data-testid="row" innerRef={listItemRef} > - - - - - - {tooltip.isOpen && ( - { - tooltip.close(); - inputRef.current?.focus(); - }} - > - - - )} - > - } - style={{ width: '100%' }} - placeholder="Category Name" - value={categoryName} - onUpdate={setCategoryName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - - + onEdit?.(category.id)} + onClick={() => onEdit?.(category.id)} data-testid="category-name" > {category.name} @@ -411,7 +297,6 @@ const ExpenseCategory = memo(function ExpenseCategory({ onOpenBudgetActionMenu?.(category.id)} + onPointerUp={() => onOpenBalanceActionMenu?.(category.id)} onPointerDown={e => e.preventDefault()} > { - onOpenBudgetActionMenu?.(null); + onOpenBalanceActionMenu?.(null); }} /> ) : ( @@ -496,7 +381,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ monthIndex={monthUtils.getMonthIndex(month)} onBudgetAction={_onBudgetAction} onClose={() => { - onOpenBudgetActionMenu?.(null); + onOpenBalanceActionMenu?.(null); }} /> ))} @@ -546,64 +431,13 @@ const ExpenseGroupTotals = memo(function ExpenseGroupTotals({ spent, balance, editMode, - isEditing, onEdit, blank, - onAddCategory, - onSave, - onDelete, show3Cols, showBudgetedCol, }) { const opacity = blank ? 0 : 1; - const showEditables = editMode || isEditing; - - const [groupName, setGroupName] = useState(group.name); - const [isHidden, setIsHidden] = useState(group.hidden); - - const tooltip = useTooltip(); - - useEffect(() => { - if (!isEditing && tooltip.isOpen) { - tooltip.close(); - } - }, [isEditing]); - - const onSubmit = () => { - if (groupName) { - onSave?.({ - ...group, - name: groupName, - }); - } else { - setGroupName(group.name); - } - onEdit?.(null); - }; - - const onMenuSelect = type => { - onEdit?.(null); - switch (type) { - case 'add-category': - onAddCategory?.(group.id, group.is_income); - break; - case 'toggle-visibility': - setIsHidden(!isHidden); - onSave?.({ - ...group, - hidden: !isHidden, - }); - break; - case 'delete': - onDelete?.(group.id); - break; - default: - throw new Error(`Unrecognized group menu type: ${type}`); - } - }; - const listItemRef = useRef(); - const inputRef = useRef(); const content = ( - - - - - - {tooltip.isOpen && ( - { - tooltip.close(); - inputRef.current?.focus(); - }} - > - - - )} - > - } - style={{ width: '100%' }} - placeholder="Category Group Name" - value={groupName} - onUpdate={setGroupName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - - + onEdit?.(group.id)} + onClick={() => onEdit?.(group.id)} data-testid="name" > {group.name} @@ -702,7 +466,6 @@ const ExpenseGroupTotals = memo(function ExpenseGroupTotals({ { - if (!isEditing && tooltip.isOpen) { - tooltip.close(); - } - }, [isEditing]); - - const onSubmit = () => { - if (groupName) { - onSave?.({ - ...group, - name: groupName, - }); - } else { - setGroupName(group.name); - } - onEdit?.(null); - }; - - const onMenuSelect = type => { - onEdit?.(null); - switch (type) { - case 'add-category': - onAddCategory?.(group.id, group.is_income); - break; - case 'toggle-visibility': - setIsHidden(!isHidden); - onSave?.({ - ...group, - hidden: !isHidden, - }); - break; - case 'delete': - onDelete?.(group.id); - break; - default: - throw new Error(`Unrecognized group menu type: ${type}`); - } - }; - const listItemRef = useRef(); - const inputRef = useRef(); return ( - - - - - - {tooltip.isOpen && ( - { - tooltip.close(); - inputRef.current?.focus(); - }} - > - - - )} - > - } - style={{ width: '100%' }} - placeholder="Category Group Name" - value={groupName} - onUpdate={setGroupName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - onEdit?.(group.id)} + onClick={() => onEdit?.(group.id)} data-testid="name" > {group.name} @@ -963,7 +609,6 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ {budgeted && ( { - if (!isEditing && tooltip.isOpen) { - tooltip.close(); - } - }, [isEditing]); - - const onSubmit = () => { - if (categoryName) { - onSave?.({ - ...category, - name: categoryName, - }); - } else { - setCategoryName(category.name); - } - onEdit?.(null); - }; - - const onMenuSelect = type => { - onEdit?.(null); - switch (type) { - case 'toggle-visibility': - setIsHidden(!isHidden); - onSave?.({ - ...category, - hidden: !isHidden, - }); - break; - case 'delete': - onDelete?.(category.id); - break; - default: - throw new Error(`Unrecognized category menu type: ${type}`); - } - }; - const listItemRef = useRef(); - const inputRef = useRef(); return ( 0 ? 1 : 0, + opacity: !!category.hidden ? 0.5 : undefined, ...style, }} innerRef={listItemRef} > - - - - - - {tooltip.isOpen && ( - { - tooltip.close(); - inputRef.current?.focus(); - }} - > - - - )} - > - } - style={{ width: '100%' }} - placeholder="Category Name" - value={categoryName} - onUpdate={setCategoryName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - onEdit?.(category.id)} + onClick={() => onEdit?.(category.id)} data-testid="name" > {category.name} @@ -1164,7 +701,6 @@ const IncomeCategory = memo(function IncomeCategory({ {budgeted && ( @@ -1348,16 +874,16 @@ const ExpenseGroup = memo(function ExpenseGroup({ {group.categories .filter(category => !category.hidden || showHiddenCategories) .map((category, index) => { - const isEditingCategory = editingCategoryId === category.id; const isEditingCategoryBudget = editingBudgetCategoryId === category.id; - const isBudgetActionMenuOpen = openBudgetActionMenuId === category.id; + const isBalanceActionMenuOpen = + openBalanceActionMenuId === category.id; return ( ); })} @@ -1412,16 +935,10 @@ function IncomeGroup({ type, group, month, - onSave, - onDelete, onAddCategory, - onSaveCategory, - onDeleteCategory, showHiddenCategories, editMode, - editingGroupId, onEditGroup, - editingCategoryId, onEditCategory, editingBudgetCategoryId, onEditCategoryBudget, @@ -1458,10 +975,7 @@ function IncomeGroup({ backgroundColor: theme.tableRowHeaderBackground, }} onAddCategory={onAddCategory} - onSave={onSave} - onDelete={onDelete} editMode={editMode} - isEditing={editingGroupId === group.id} onEdit={onEditGroup} /> @@ -1471,6 +985,7 @@ function IncomeGroup({ return ( { return { @@ -1557,24 +1065,21 @@ function BudgetGroups({ gestures={gestures} month={month} editMode={editMode} - editingGroupId={editingGroupId} onEditGroup={onEditGroup} - editingCategoryId={editingCategoryId} onEditCategory={onEditCategory} editingBudgetCategoryId={editingBudgetCategoryId} onEditCategoryBudget={onEditCategoryBudget} - openBudgetActionMenuId={openBudgetActionMenuId} - onOpenBudgetActionMenu={onOpenBudgetActionMenu} + openBalanceActionMenuId={openBalanceActionMenuId} + onOpenBalanceActionMenu={onOpenBalanceActionMenu} onSaveCategory={onSaveCategory} onDeleteCategory={onDeleteCategory} onAddCategory={onAddCategory} - onSave={onSaveGroup} - onDelete={onDeleteGroup} onReorderCategory={onReorderCategory} onReorderGroup={onReorderGroup} onBudgetAction={onBudgetAction} show3Cols={show3Cols} showHiddenCategories={showHiddenCategories} + pushModal={pushModal} /> ); })} @@ -1585,7 +1090,7 @@ function BudgetGroups({ justifyContent: 'flex-start', }} > - + Add Group @@ -1595,101 +1100,60 @@ function BudgetGroups({ type={type} group={incomeGroup} month={month} - onSave={onSaveGroup} - onDelete={onDeleteGroup} onAddCategory={onAddCategory} onSaveCategory={onSaveCategory} onDeleteCategory={onDeleteCategory} showHiddenCategories={showHiddenCategories} editMode={editMode} - editingGroupId={editingGroupId} onEditGroup={onEditGroup} - editingCategoryId={editingCategoryId} onEditCategory={onEditCategory} editingBudgetCategoryId={editingBudgetCategoryId} onEditCategoryBudget={onEditCategoryBudget} onBudgetAction={onBudgetAction} + pushModal={pushModal} /> )} ); } -export function BudgetTable(props) { - const { - type, - categoryGroups, - month, - monthBounds, - editMode, - // refreshControl, - onPrevMonth, - onNextMonth, - onSaveGroup, - onDeleteGroup, - onAddGroup, - onAddCategory, - onSaveCategory, - onDeleteCategory, - onEditMode, - onReorderCategory, - onReorderGroup, - onShowBudgetSummary, - // onOpenActionSheet, - onBudgetAction, - onRefresh, - onSwitchBudgetType, - savePrefs, - pushModal, - } = props; - - const GROUP_EDIT_ACTION = 'group'; - const [editingGroupId, setEditingGroupId] = useState(null); - function onEditGroup(id) { - onEdit(GROUP_EDIT_ACTION, id); - } - - const CATEGORY_EDIT_ACTION = 'category'; - const [editingCategoryId, setEditingCategoryId] = useState(null); - function onEditCategory(id) { - onEdit(CATEGORY_EDIT_ACTION, id); - } - - const CATEGORY_BUDGET_EDIT_ACTION = 'category-budget'; - const [editingBudgetCategoryId, setEditingBudgetCategoryId] = useState(null); - function onEditCategoryBudget(id) { - onEdit(CATEGORY_BUDGET_EDIT_ACTION, id); - } - - const BUDGET_MENU_OPEN_ACTION = 'budget-menu'; - const [openBudgetActionMenuId, setOpenBudgetActionMenuId] = useState(null); - function onOpenBudgetActionMenu(id) { - onEdit(BUDGET_MENU_OPEN_ACTION, id); - } - - function onEdit(action, id) { - // Do not allow editing if another field is currently being edited. - // Cancel the currently editing field in that case. - const currentlyEditing = - editingGroupId || - editingCategoryId || - editingBudgetCategoryId || - openBudgetActionMenuId; - - setEditingGroupId( - action === GROUP_EDIT_ACTION && !currentlyEditing ? id : null, - ); - setEditingCategoryId( - action === CATEGORY_EDIT_ACTION && !currentlyEditing ? id : null, - ); - setEditingBudgetCategoryId( - action === CATEGORY_BUDGET_EDIT_ACTION && !currentlyEditing ? id : null, - ); - setOpenBudgetActionMenuId( - action === BUDGET_MENU_OPEN_ACTION && !currentlyEditing ? id : null, - ); - } - +export function BudgetTable({ + type, + categoryGroups, + categories, + month, + monthBounds, + editMode, + // refreshControl, + onPrevMonth, + onNextMonth, + onSaveGroup, + onDeleteGroup, + onAddGroup, + onAddCategory, + onSaveCategory, + onDeleteCategory, + onEditMode, + onReorderCategory, + onReorderGroup, + onShowBudgetSummary, + onOpenMonthActionMenu, + onBudgetAction, + onRefresh, + onSwitchBudgetType, + onSaveNotes, + onEditGroupNotes, + onEditCategoryNotes, + savePrefs, + pushModal, + onEditGroup, + onEditCategory, + editingBudgetCategoryId, + onEditCategoryBudget, + openBalanceActionMenuId, + onOpenBalanceActionMenu, + ...props +}) { const { width } = useResponsive(); const show3Cols = width >= 360; @@ -1748,7 +1212,7 @@ export function BudgetTable(props) { } headerRightContent={ !editMode ? ( - ) : ( @@ -1954,7 +1418,7 @@ export function BudgetTable(props) { // scrollRef, // onScroll // }) => ( - + @@ -1990,7 +1456,7 @@ export function BudgetTable(props) { ); } -function BudgetMenu({ +function BudgetPageMenu({ onEditMode, onToggleHiddenCategories, onSwitchBudgetType, @@ -2044,7 +1510,8 @@ function BudgetMenu({ = { header?: ReactNode; footer?: ReactNode; - items: Array; - onMenuSelect: (itemName: MenuItem['name']) => void; + items: Array; + onMenuSelect: (itemName: T['name']) => void; + style?: CSSProperties; }; -export default function Menu({ +export default function Menu({ header, footer, items: allItems, onMenuSelect, -}: MenuProps) { + style, +}: MenuProps) { const elRef = useRef(null); const items = allItems.filter(x => x); const [hoveredIndex, setHoveredIndex] = useState(null); @@ -101,7 +104,7 @@ export default function Menu({ return ( @@ -155,6 +158,7 @@ export default function Menu({ backgroundColor: theme.menuItemBackgroundHover, color: theme.menuItemTextHover, }), + ...item.style, }} onMouseEnter={() => setHoveredIndex(idx)} onMouseLeave={() => setHoveredIndex(null)} @@ -168,7 +172,10 @@ export default function Menu({ createElement(item.icon, { width: item.iconSize || 10, height: item.iconSize || 10, - style: { marginRight: 7, width: 10 }, + style: { + marginRight: 7, + width: item.iconSize || 10, + }, })} {item.text} diff --git a/packages/desktop-client/src/components/common/Modal.tsx b/packages/desktop-client/src/components/common/Modal.tsx index 6f1de3b2f08..4daa0c31f29 100644 --- a/packages/desktop-client/src/components/common/Modal.tsx +++ b/packages/desktop-client/src/components/common/Modal.tsx @@ -3,6 +3,7 @@ import React, { useRef, useLayoutEffect, type ReactNode, + useState, } from 'react'; import ReactModal from 'react-modal'; @@ -14,18 +15,25 @@ import { type CSSProperties, styles, theme } from '../../style'; import tokens from '../../tokens'; import Button from './Button'; +import Input from './Input'; import Text from './Text'; import View from './View'; +type ModalChildrenProps = { + isEditingTitle: boolean; +}; + export type ModalProps = { title?: string; isCurrent?: boolean; isHidden?: boolean; - children: ReactNode | (() => ReactNode); - size?: { width?: number; height?: number }; + children: ReactNode | ((props: ModalChildrenProps) => ReactNode); + size?: { width?: CSSProperties['width']; height?: CSSProperties['height'] }; padding?: CSSProperties['padding']; showHeader?: boolean; + leftHeaderContent?: ReactNode; showTitle?: boolean; + editableTitle?: boolean; showClose?: boolean; showOverlay?: boolean; loading?: boolean; @@ -34,9 +42,11 @@ export type ModalProps = { stackIndex?: number; parent?: HTMLElement; style?: CSSProperties; + titleStyle?: CSSProperties; contentStyle?: CSSProperties; overlayStyle?: CSSProperties; onClose?: () => void; + onTitleUpdate?: (title: string) => void; }; const Modal = ({ @@ -46,7 +56,9 @@ const Modal = ({ size, padding = 20, showHeader = true, + leftHeaderContent, showTitle = true, + editableTitle = false, showClose = true, showOverlay = true, loading = false, @@ -55,10 +67,12 @@ const Modal = ({ stackIndex, parent, style, + titleStyle, contentStyle, overlayStyle, children, onClose, + onTitleUpdate, }: ModalProps) => { useEffect(() => { // This deactivates any key handlers in the "app" scope. Ideally @@ -69,22 +83,39 @@ const Modal = ({ return () => hotkeys.setScope(prevScope); }, []); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [_title, setTitle] = useState(title); + + const onTitleClick = () => { + setIsEditingTitle(true); + }; + + const _onTitleUpdate = newTitle => { + if (newTitle !== title) { + onTitleUpdate?.(newTitle); + } + setIsEditingTitle(false); + }; + return ( parent)} style={{ content: { + display: 'flex', + height: 'fit-content', + width: 'fit-content', + position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, - display: 'flex', justifyContent: 'center', alignItems: 'center', overflow: 'visible', @@ -93,10 +124,11 @@ const Modal = ({ backgroundColor: 'transparent', padding: 0, pointerEvents: 'auto', - margin: '0 10px', + margin: 'auto', ...contentStyle, }, overlay: { + display: 'flex', zIndex: 3000, backgroundColor: showOverlay && stackIndex === 0 ? 'rgba(0, 0, 0, .1)' : 'none', @@ -120,7 +152,8 @@ const Modal = ({ size={size} style={{ willChange: 'opacity, transform', - minWidth: '100%', + maxWidth: '90vw', + minWidth: '90vw', minHeight: 0, borderRadius: 4, //border: '1px solid ' + theme.modalBorder, @@ -143,6 +176,28 @@ const Modal = ({ flexShrink: 0, }} > + + + {leftHeaderContent && !isEditingTitle + ? leftHeaderContent + : null} + + + {showTitle && ( - - {title} - + {isEditingTitle ? ( + setTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + _onTitleUpdate(e.currentTarget.value); + } + }} + onBlur={e => _onTitleUpdate(e.target.value)} + /> + ) : ( + + {_title} + + )} )} @@ -185,7 +261,7 @@ const Modal = ({ marginRight: 15, }} > - {showClose && ( + {showClose && !isEditingTitle && ( )} - {typeof children === 'function' ? children() : children} + {typeof children === 'function' + ? children({ isEditingTitle }) + : children} {loading && ( ( + null, + ); - async function onDeleteLocal() { - setLoadingState('local'); - await actions.deleteBudget(file.id); + async function onDelete() { + setLoadingState(isCloudFile ? 'cloud' : 'local'); + await actions.deleteBudget( + 'id' in file ? file.id : undefined, + isCloudFile ? file.cloudFileId : undefined, + ); setLoadingState(null); modalProps.onBack(); } - // If the state is "broken" that means it was created by another - // user. The current user should be able to delete the local file, - // but not the remote one - const isRemote = file.cloudFileId && file.state !== 'broken'; - return ( - {isRemote && ( + {isCloudFile && ( <> This is a hosted file which means it is stored @@ -68,16 +79,16 @@ export default function DeleteMenu({ modalProps, actions, file }) { padding: '10px 30px', fontSize: 14, }} - onClick={onDeleteCloud} + onClick={onDelete} > Delete file from all devices > )} - {file.id && ( + {'id' in file && ( <> - {isRemote ? ( + {isCloudFile ? ( You can also delete just the local copy. This will remove all local data and the file will be listed as available for @@ -101,14 +112,14 @@ export default function DeleteMenu({ modalProps, actions, file }) { )} Delete file locally diff --git a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx b/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx index 4155bffab6a..207912155ff 100644 --- a/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx +++ b/packages/desktop-client/src/components/mobile/MobileAmountInput.jsx @@ -1,4 +1,4 @@ -import { PureComponent } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; import { toRelaxedNumber, @@ -11,302 +11,218 @@ import Button from '../common/Button'; import Text from '../common/Text'; import View from '../common/View'; -function getValue(state) { - const { value } = state; - return value; -} - -class AmountInput extends PureComponent { - static getDerivedStateFromProps(props, state) { - return { editing: state.text !== '' || state.editing }; - } - - constructor(props) { - super(props); - // this.backgroundValue = new Animated.Value(0); - this.backgroundValue = 0; - - this.id = Math.random().toString().slice(0, 5); - this.state = { - editing: false, - text: '', - // These are actually set from the props when the field is - // focused - value: 0, - }; - } - - componentDidMount() { - if (this.props.focused) { - this.focus(); +const AmountInput = memo(function AmountInput({ + focused, + style, + textStyle, + ...props +}) { + const [editing, setEditing] = useState(false); + const [text, setText] = useState(''); + const [value, setValue] = useState(0); + const inputRef = useRef(); + + const getInitialValue = () => Math.abs(props.value); + + useEffect(() => { + if (focused) { + focus(); } - } + }, []); - componentWillUnmount() { - if (this.removeListeners) { - this.removeListeners(); - } - } - - componentDidUpdate(prevProps, prevState) { - if (!prevProps.focused && this.props.focused) { - this.focus(); - } + useEffect(() => { + setEditing(text !== ''); + }, [text]); - if (prevProps.value !== this.props.value) { - this.setState({ - editing: false, - text: '', - ...this.getInitialValue(), - }); + useEffect(() => { + if (focused) { + focus(); } - } + }, [focused]); - parseText() { - return toRelaxedNumber( - this.state.text.replace(/[,.]/, getNumberFormat().separator), - ); - } + useEffect(() => { + setEditing(false); + setText(''); + setValue(getInitialValue()); + }, [props.value]); - // animate() { - // this.animation = Animated.sequence([ - // Animated.timing(this.backgroundValue, { - // toValue: 1, - // duration: 1200, - // useNativeDriver: true, - // }), - // Animated.timing(this.backgroundValue, { - // toValue: 0, - // duration: 1200, - // useNativeDriver: true, - // }), - // ]); - - // this.animation.start(({ finished }) => { - // if (finished) { - // this.animate(); - // } - // }); - // } + const parseText = () => { + return toRelaxedNumber(text.replace(/[,.]/, getNumberFormat().separator)); + }; - onKeyPress = e => { - if (e.nativeEvent.key === 'Backspace' && this.state.text === '') { - this.setState({ editing: true }); + const onKeyPress = e => { + if (e.key === 'Backspace' && text === '') { + setEditing(true); } }; - getInitialValue() { - return { - value: Math.abs(this.props.value), - }; - } - - focus() { - this.input.focus(); - - const initialState = this.getInitialValue(); - this.setState(initialState); - } - - applyText = () => { - const { editing } = this.state; + const focus = () => { + inputRef.current?.focus(); + setValue(getInitialValue()); + }; - const parsed = this.parseText(); - const newValue = editing ? parsed : getValue(this.state); + const applyText = () => { + const parsed = parseText(); + const newValue = editing ? parsed : value; - this.setState({ - value: Math.abs(newValue), - editing: false, - text: '', - }); + setValue(Math.abs(newValue)); + setEditing(false); + setText(''); return newValue; }; - onBlur = () => { - const value = this.applyText(); - this.props.onBlur?.(value); - if (this.removeListeners) { - this.removeListeners(); - } + const onBlur = () => { + const value = applyText(); + props.onUpdate?.(value); }; - onChangeText = text => { - const { onChange } = this.props; - - this.setState({ text }); - onChange(text); + const onChangeText = text => { + setText(text); + props.onChange?.(text); }; - render() { - const { style, textStyle } = this.props; - const { editing, value, text } = this.state; - - const input = ( - (this.input = el)} - value={text} - inputMode="decimal" - autoCapitalize="none" - onChange={e => this.onChangeText(e.target.value)} - onBlur={this.onBlur} - onKeyPress={this.onKeyPress} - data-testid="amount-input" - style={{ flex: 1, textAlign: 'center', position: 'absolute' }} - /> - ); - - return ( - onChangeText(e.target.value)} + onBlur={onBlur} + onKeyUp={onKeyPress} + data-testid="amount-input" + style={{ flex: 1, textAlign: 'center', position: 'absolute' }} + /> + ); + + return ( + + {input} + - {input} - - {/* */} - - {editing ? text : amountToCurrency(value)} - - - ); - } -} - -export class FocusableAmountInput extends PureComponent { - state = { focused: false, isNegative: true }; - - componentDidMount() { - if (this.props.sign) { - this.setState({ isNegative: this.props.sign === 'negative' }); - } else if ( - this.props.value > 0 || - (!this.props.zeroIsNegative && this.props.value === 0) - ) { - this.setState({ isNegative: false }); + {editing ? text : amountToCurrency(value)} + + + ); +}); + +export const FocusableAmountInput = memo(function FocusableAmountInput({ + value, + sign, // + or - + zeroSign, // + or - + focused, + textStyle, + style, + focusedStyle, + buttonProps, + onFocus, + ...props +}) { + const [isNegative, setIsNegative] = useState(true); + + useEffect(() => { + if (sign) { + setIsNegative(sign === '-'); + } else if (value > 0 || (zeroSign !== '-' && value === 0)) { + setIsNegative(false); } - } - - focus = () => { - this.setState({ focused: true }); - }; + }, []); - onFocus = () => { - this.focus(); + const toggleIsNegative = () => { + setIsNegative(!isNegative); + props.onUpdate?.(maybeApplyNegative(value, !isNegative)); }; - toggleIsNegative = () => { - this.setState({ isNegative: !this.state.isNegative }, () => { - this.onBlur(this.props.value); - }); + const maybeApplyNegative = (val, negative) => { + const absValue = Math.abs(val); + return negative ? -absValue : absValue; }; - maybeApplyNegative = value => { - const absValue = Math.abs(value); - return this.state.isNegative ? -absValue : absValue; + const onUpdate = val => { + props.onUpdate?.(maybeApplyNegative(val, isNegative)); }; - onBlur = value => { - this.setState({ focused: false, reallyFocused: false }); - this.props.onBlur?.(this.maybeApplyNegative(value)); + const onChange = val => { + props.onChange?.(maybeApplyNegative(val, isNegative)); }; - onChange = value => this.props.onChange?.(this.maybeApplyNegative(value)); - - render() { - const { textStyle, style, focusedStyle, buttonProps } = this.props; - const { focused } = this.state; + return ( + + - return ( - (this.amount = el)} - onChange={this.onChange} - onBlur={this.onBlur} - focused={focused} - style={{ - width: 80, - transform: [{ translateX: 6 }], - justifyContent: 'center', - ...style, - ...focusedStyle, - ...(!focused && { - opacity: 0, - position: 'absolute', - top: 0, - }), - }} - textStyle={{ fontSize: 15, textAlign: 'right', ...textStyle }} - /> - - - {!focused && ( - - {this.state.isNegative ? '−' : '+'} - - )} + {!focused && ( - - - {amountToCurrency(Math.abs(this.props.value))} - - + {isNegative ? '-' : '+'} - + )} + + + + {amountToCurrency(Math.abs(value))} + + + - ); - } -} + + ); +}); diff --git a/packages/desktop-client/src/components/mobile/MobileForms.jsx b/packages/desktop-client/src/components/mobile/MobileForms.jsx index c7f084574ec..4a7334fbd84 100644 --- a/packages/desktop-client/src/components/mobile/MobileForms.jsx +++ b/packages/desktop-client/src/components/mobile/MobileForms.jsx @@ -18,7 +18,7 @@ export function FieldLabel({ title, flush, style }) { marginTop: flush ? 0 : 25, fontSize: 13, color: theme.tableRowHeaderText, - paddingLeft: styles.mobileEditingPadding, + padding: `0 ${styles.mobileEditingPadding}px`, textTransform: 'uppercase', userSelect: 'none', ...style, @@ -35,7 +35,6 @@ const valueStyle = { marginLeft: 8, marginRight: 8, height: FIELD_HEIGHT, - paddingHorizontal: styles.mobileEditingPadding, }; export const InputField = forwardRef(function InputField( @@ -44,7 +43,7 @@ export const InputField = forwardRef(function InputField( ) { return ( void; + onAddCategory: (groupId: string, isIncome: boolean) => void; + onEditNotes: (id: string) => void; + onSaveNotes: (id: string, notes: string) => void; + onDelete: (groupId: string) => void; + onClose?: () => void; +}; + +export default function CategoryGroupMenu({ + modalProps, + groupId, + onSave, + onAddCategory, + onEditNotes, + onDelete, + onClose, +}: CategoryGroupMenuProps) { + const { grouped: categoryGroups } = useCategories(); + const group = categoryGroups.find(g => g.id === groupId); + const data = useLiveQuery( + () => q('notes').filter({ id: group.id }).select('*'), + [group.id], + ); + const notes = data && data.length > 0 ? data[0].note : null; + + function _onClose() { + modalProps?.onClose(); + onClose?.(); + } + + function _onRename(newName) { + if (newName !== group.name) { + onSave?.({ + ...group, + name: newName, + }); + } + } + + function _onAddCategory() { + onAddCategory?.(group.id, group.is_income); + } + + function _onEditNotes() { + onEditNotes?.(group.id); + } + + function _onToggleVisibility() { + onSave?.({ + ...group, + hidden: !!!group.hidden, + }); + _onClose(); + } + + function _onDelete() { + onDelete?.(group.id); + } + + function onNameUpdate(newName) { + _onRename(newName); + } + + const buttonStyle: CSSProperties = { + ...styles.mediumText, + height: BUTTON_HEIGHT, + color: theme.formLabelText, + // Adjust based on desired number of buttons per row. + flexBasis: '48%', + marginLeft: '1%', + marginRight: '1%', + }; + + return ( + + } + > + {({ isEditingTitle }) => ( + + + 0 ? notes : 'No notes'} + editable={false} + focused={false} + getStyle={editable => ({ + ...styles.mediumText, + borderRadius: 6, + ...((!notes || notes.length === 0) && { + justifySelf: 'center', + alignSelf: 'center', + color: theme.pageTextSubdued, + }), + })} + /> + + + + + Add category + + + + Edit notes + + + + )} + + ); +} + +function AdditionalCategoryGroupMenu({ group, onDelete, onToggleVisibility }) { + const [menuOpen, setMenuOpen] = useState(false); + const itemStyle: CSSProperties = { + ...styles.mediumText, + height: BUTTON_HEIGHT, + }; + + return ( + + { + setMenuOpen(true); + }} + > + + {menuOpen && ( + { + setMenuOpen(false); + }} + > + i != null) as ComponentProps['items'] + } + onMenuSelect={itemName => { + setMenuOpen(false); + if (itemName === 'delete') { + onDelete(); + } else if (itemName === 'toggleVisibility') { + onToggleVisibility(); + } + }} + /> + + )} + + + ); +} diff --git a/packages/desktop-client/src/components/modals/CategoryMenu.tsx b/packages/desktop-client/src/components/modals/CategoryMenu.tsx new file mode 100644 index 00000000000..5ad9f6fc019 --- /dev/null +++ b/packages/desktop-client/src/components/modals/CategoryMenu.tsx @@ -0,0 +1,230 @@ +import React, { useState } from 'react'; + +import { useLiveQuery } from 'loot-core/src/client/query-hooks'; +import q from 'loot-core/src/shared/query'; +import { type CategoryEntity } from 'loot-core/src/types/models'; + +import useCategories from '../../hooks/useCategories'; +import { DotsHorizontalTriple } from '../../icons/v1'; +import Trash from '../../icons/v1/Trash'; +import NotesPaper from '../../icons/v2/NotesPaper'; +import ViewHide from '../../icons/v2/ViewHide'; +import ViewShow from '../../icons/v2/ViewShow'; +import { type CSSProperties, styles, theme } from '../../style'; +import { type CommonModalProps } from '../../types/modals'; +import Button from '../common/Button'; +import Menu from '../common/Menu'; +import Modal from '../common/Modal'; +import View from '../common/View'; +import Notes from '../Notes'; +import { Tooltip } from '../tooltips'; + +const BUTTON_HEIGHT = 40; + +type CategoryMenuProps = { + modalProps: CommonModalProps; + categoryId: string; + onSave: (category: CategoryEntity) => void; + onEditNotes: (id: string) => void; + onDelete: (categoryId: string) => void; + onClose?: () => void; +}; + +export default function CategoryMenu({ + modalProps, + categoryId, + onSave, + onEditNotes, + onDelete, + onClose, +}: CategoryMenuProps) { + const { list: categories } = useCategories(); + const category = categories.find(c => c.id === categoryId); + const data = useLiveQuery( + () => q('notes').filter({ id: category.id }).select('*'), + [category.id], + ); + const originalNotes = data && data.length > 0 ? data[0].note : null; + + function _onClose() { + modalProps?.onClose(); + onClose?.(); + } + + function _onRename(newName) { + if (newName !== category.name) { + onSave?.({ + ...category, + name: newName, + }); + } + } + + function _onToggleVisibility() { + onSave?.({ + ...category, + hidden: !category.hidden, + }); + _onClose(); + } + + function _onEditNotes() { + onEditNotes?.(category.id); + } + + function _onDelete() { + onDelete?.(category.id); + } + + function onNameUpdate(newName) { + _onRename(newName); + } + + const buttonStyle: CSSProperties = { + ...styles.mediumText, + height: BUTTON_HEIGHT, + color: theme.formLabelText, + // Adjust based on desired number of buttons per row. + flexBasis: '100%', + }; + + return ( + + } + > + {({ isEditingTitle }) => ( + + + 0 ? originalNotes : 'No notes'} + editable={false} + focused={false} + getStyle={editable => ({ + borderRadius: 6, + ...((!originalNotes || originalNotes.length === 0) && { + justifySelf: 'center', + alignSelf: 'center', + color: theme.pageTextSubdued, + }), + })} + /> + + + + + Edit notes + + + + )} + + ); +} + +function AdditionalCategoryMenu({ category, onDelete, onToggleVisibility }) { + const [menuOpen, setMenuOpen] = useState(false); + const itemStyle: CSSProperties = { + ...styles.mediumText, + height: BUTTON_HEIGHT, + }; + + return ( + + { + setMenuOpen(true); + }} + > + + {menuOpen && ( + { + setMenuOpen(false); + }} + > + { + setMenuOpen(false); + if (itemName === 'delete') { + onDelete(); + } else if (itemName === 'toggleVisibility') { + onToggleVisibility(); + } + }} + /> + + )} + + + ); +} diff --git a/packages/desktop-client/src/components/modals/EditField.jsx b/packages/desktop-client/src/components/modals/EditField.jsx index 853c85534be..abd07d79fea 100644 --- a/packages/desktop-client/src/components/modals/EditField.jsx +++ b/packages/desktop-client/src/components/modals/EditField.jsx @@ -34,7 +34,7 @@ function CreatePayeeIcon(props) { return ; } -export default function EditField({ modalProps, name, onSubmit }) { +export default function EditField({ modalProps, name, onSubmit, onClose }) { const dateFormat = useSelector( state => state.prefs.local.dateFormat || 'MM/dd/yyyy', ); @@ -44,6 +44,11 @@ export default function EditField({ modalProps, name, onSubmit }) { const { createPayee } = useActions(); + const onCloseInner = () => { + modalProps.onClose(); + onClose?.(); + }; + function onSelect(value) { if (value != null) { // Process the value if needed @@ -53,7 +58,7 @@ export default function EditField({ modalProps, name, onSubmit }) { onSubmit(name, value); } - modalProps.onClose(); + onCloseInner(); } const itemStyle = { @@ -268,6 +273,7 @@ export default function EditField({ modalProps, name, onSubmit }) { showHeader={isNarrowWidth} focusAfterClose={false} {...modalProps} + onClose={onCloseInner} padding={0} style={{ flex: 0, diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index 9893fa71f43..ee0707bad80 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -768,7 +768,7 @@ export default function EditRule({ title="Rule" padding={0} {...modalProps} - style={{ ...modalProps.style, flex: 'inherit', maxWidth: '90%' }} + style={{ ...modalProps.style, flex: 'inherit' }} > {() => ( {() => } diff --git a/packages/desktop-client/src/components/modals/Notes.tsx b/packages/desktop-client/src/components/modals/Notes.tsx new file mode 100644 index 00000000000..3ae4cd95cd4 --- /dev/null +++ b/packages/desktop-client/src/components/modals/Notes.tsx @@ -0,0 +1,99 @@ +import React, { useEffect, useState } from 'react'; + +import { useLiveQuery } from 'loot-core/src/client/query-hooks'; +import q from 'loot-core/src/shared/query'; + +import Check from '../../icons/v2/Check'; +import { type CommonModalProps } from '../../types/modals'; +import Button from '../common/Button'; +import Modal from '../common/Modal'; +import View from '../common/View'; +import NotesComponent from '../Notes'; + +type NotesProps = { + modalProps: CommonModalProps; + id: string; + name: string; + onSave: (id: string, notes: string) => void; +}; + +export default function Notes({ modalProps, id, name, onSave }: NotesProps) { + const data = useLiveQuery(() => q('notes').filter({ id }).select('*'), [id]); + const originalNotes = data && data.length > 0 ? data[0].note : null; + + const [notes, setNotes] = useState(originalNotes); + useEffect(() => setNotes(originalNotes), [originalNotes]); + + function _onClose() { + modalProps?.onClose(); + } + + function _onSave() { + if (notes !== originalNotes) { + onSave?.(id, notes); + } + + _onClose(); + } + + return ( + + {() => ( + + ({ + borderRadius: 6, + flex: 1, + minWidth: 0, + })} + onChange={setNotes} + /> + + + + Save notes + + + + )} + + ); +} diff --git a/packages/desktop-client/src/components/modals/SingleInput.tsx b/packages/desktop-client/src/components/modals/SingleInput.tsx index 675a4413260..db2ed81a088 100644 --- a/packages/desktop-client/src/components/modals/SingleInput.tsx +++ b/packages/desktop-client/src/components/modals/SingleInput.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; +import { styles } from '../../style'; import { type CommonModalProps } from '../../types/modals'; import Button from '../common/Button'; import FormError from '../common/FormError'; @@ -53,6 +54,7 @@ function SingleInput({ _onSubmit(e.currentTarget.value)} /> @@ -68,11 +70,20 @@ function SingleInput({ - _onSubmit(value)}>{buttonText} + _onSubmit(value)} + > + {buttonText} + > )} diff --git a/packages/desktop-client/src/components/payees/PayeeTable.tsx b/packages/desktop-client/src/components/payees/PayeeTable.tsx index b376752ae30..c7504d76cfe 100644 --- a/packages/desktop-client/src/components/payees/PayeeTable.tsx +++ b/packages/desktop-client/src/components/payees/PayeeTable.tsx @@ -1,10 +1,8 @@ import { - forwardRef, useCallback, useLayoutEffect, useState, type ComponentProps, - type ComponentRef, } from 'react'; import { type PayeeEntity } from 'loot-core/src/types/models'; @@ -20,6 +18,7 @@ import PayeeTableRow from './PayeeTableRow'; type PayeeWithId = PayeeEntity & Required>; type PayeeTableProps = { + tableRef: ComponentProps>['tableRef']; payees: PayeeWithId[]; ruleCounts: Map; navigator: TableNavigator; @@ -28,56 +27,56 @@ type PayeeTableProps = { 'onUpdate' | 'onViewRules' | 'onCreateRule' >; -const PayeeTable = forwardRef< - ComponentRef>, - PayeeTableProps ->( - ( - { payees, ruleCounts, navigator, onUpdate, onViewRules, onCreateRule }, - ref, - ) => { - const [hovered, setHovered] = useState(null); - const selectedItems = useSelectedItems(); +const PayeeTable = ({ + tableRef, + payees, + ruleCounts, + navigator, + onUpdate, + onViewRules, + onCreateRule, +}: PayeeTableProps) => { + const [hovered, setHovered] = useState(null); + const selectedItems = useSelectedItems(); - useLayoutEffect(() => { - const firstSelected = [...selectedItems][0] as string; - if (typeof ref !== 'function') { - ref.current.scrollTo(firstSelected, 'center'); - } - navigator.onEdit(firstSelected, 'select'); - }, []); + useLayoutEffect(() => { + const firstSelected = [...selectedItems][0] as string; + if (typeof tableRef !== 'function') { + tableRef.current.scrollTo(firstSelected, 'center'); + } + navigator.onEdit(firstSelected, 'select'); + }, []); - const onHover = useCallback(id => { - setHovered(id); - }, []); + const onHover = useCallback((id: string) => { + setHovered(id); + }, []); - return ( - setHovered(null)}> - - ref={ref} - items={payees} - navigator={navigator} - renderItem={({ item, editing, focusedField, onEdit }) => { - return ( - - ); - }} - /> - - ); - }, -); + return ( + setHovered(null)}> + { + return ( + + ); + }} + /> + + ); +}; export default PayeeTable; diff --git a/packages/desktop-client/src/components/reports/ChooseGraph.tsx b/packages/desktop-client/src/components/reports/ChooseGraph.tsx index 0c8825046ca..66574f564d7 100644 --- a/packages/desktop-client/src/components/reports/ChooseGraph.tsx +++ b/packages/desktop-client/src/components/reports/ChooseGraph.tsx @@ -48,9 +48,19 @@ function ChooseGraph({ const listScrollRef = useRef(null); const totalScrollRef = useRef(null); - const handleScrollTotals = scroll => { - headerScrollRef.current.scrollLeft = scroll.target.scrollLeft; - listScrollRef.current.scrollLeft = scroll.target.scrollLeft; + const handleScroll = scroll => { + if (scroll.target.id === 'header') { + totalScrollRef.current.scrollLeft = scroll.target.scrollLeft; + listScrollRef.current.scrollLeft = scroll.target.scrollLeft; + } + if (scroll.target.id === 'total') { + headerScrollRef.current.scrollLeft = scroll.target.scrollLeft; + listScrollRef.current.scrollLeft = scroll.target.scrollLeft; + } + if (scroll.target.id === 'list') { + headerScrollRef.current.scrollLeft = scroll.target.scrollLeft; + totalScrollRef.current.scrollLeft = scroll.target.scrollLeft; + } }; if (graphType === 'AreaGraph') { @@ -96,7 +106,8 @@ function ChooseGraph({ ; style?: CSSProperties; children?: ReactNode; + handleScroll?: UIEventHandler; }; export default function ReportTable({ @@ -16,6 +22,7 @@ export default function ReportTable({ listScrollRef, style, children, + handleScroll, }: ReportTableProps) { const contentRef = useRef(null); @@ -28,6 +35,8 @@ export default function ReportTable({ return ( {children} diff --git a/packages/desktop-client/src/components/reports/ReportTableHeader.tsx b/packages/desktop-client/src/components/reports/ReportTableHeader.tsx index 7a1f5be90e5..da1e795258a 100644 --- a/packages/desktop-client/src/components/reports/ReportTableHeader.tsx +++ b/packages/desktop-client/src/components/reports/ReportTableHeader.tsx @@ -1,55 +1,60 @@ -import React, { type Ref } from 'react'; - -import * as d from 'date-fns'; +import React, { type UIEventHandler } from 'react'; +import { type RefProp } from 'react-spring'; import { styles, theme } from '../../style'; import View from '../common/View'; import { Row, Cell } from '../table'; -import { type Month } from './entities'; +import { type MonthData } from './entities'; type ReportTableHeaderProps = { scrollWidth?: number; groupBy: string; - interval?: Month[]; + interval?: MonthData[]; balanceType: string; - headerScrollRef?: Ref; + headerScrollRef: RefProp; + handleScroll?: UIEventHandler; }; -export default function ReportTableHeader({ +function ReportTableHeader({ scrollWidth, groupBy, interval, balanceType, headerScrollRef, + handleScroll, }: ReportTableHeaderProps) { return ( - - {interval ? interval.map((header, index) => { @@ -60,8 +65,7 @@ export default function ReportTableHeader({ ...styles.tnum, }} key={index} - // eslint-disable-next-line rulesdir/typography - value={d.format(d.parseISO(`${header}-01`), "MMM ''yy")} + value={header.date} width="flex" /> ); @@ -102,8 +106,9 @@ export default function ReportTableHeader({ value="Average" width="flex" /> - {scrollWidth > 0 && } - - + + ); } + +export default ReportTableHeader; diff --git a/packages/desktop-client/src/components/reports/ReportTableList.tsx b/packages/desktop-client/src/components/reports/ReportTableList.tsx index ddfdeca2c40..40939b0e640 100644 --- a/packages/desktop-client/src/components/reports/ReportTableList.tsx +++ b/packages/desktop-client/src/components/reports/ReportTableList.tsx @@ -47,10 +47,10 @@ const TableRow = memo( > 12 && item[groupByItem]} style={{ - minWidth: 125, + width: 120, + flexShrink: 0, ...styles.tnum, }} /> diff --git a/packages/desktop-client/src/components/reports/ReportTableTotals.tsx b/packages/desktop-client/src/components/reports/ReportTableTotals.tsx index 625ba16f81f..3fa158a03b9 100644 --- a/packages/desktop-client/src/components/reports/ReportTableTotals.tsx +++ b/packages/desktop-client/src/components/reports/ReportTableTotals.tsx @@ -1,4 +1,4 @@ -import React, { type UIEventHandler } from 'react'; +import React, { type UIEventHandler, useLayoutEffect, useState } from 'react'; import { type RefProp } from 'react-spring'; import { @@ -20,45 +20,63 @@ type ReportTableTotalsProps = { mode: string; monthsCount: number; totalScrollRef: RefProp; - handleScrollTotals: UIEventHandler; + handleScroll: UIEventHandler; }; -export default function ReportTableTotals({ +function ReportTableTotals({ data, scrollWidth, balanceTypeOp, mode, monthsCount, totalScrollRef, - handleScrollTotals, + handleScroll, }: ReportTableTotalsProps) { + const [scrollWidthTotals, setScrollWidthTotals] = useState(0); + + useLayoutEffect(() => { + if (totalScrollRef.current) { + const [parent, child] = [ + totalScrollRef.current.offsetParent + ? totalScrollRef.current.parentElement.scrollHeight + : 0, + totalScrollRef.current ? totalScrollRef.current.scrollHeight : 0, + ]; + setScrollWidthTotals(parent > 0 && child > 0 && parent - child); + } + }); + const average = amountToInteger(data[balanceTypeOp]) / monthsCount; return ( - - {mode === 'time' ? data.monthData.map(item => { @@ -133,9 +151,9 @@ export default function ReportTableTotals({ width="flex" privacyFilter /> - - {scrollWidth > 0 && } - - + + ); } + +export default ReportTableTotals; diff --git a/packages/desktop-client/src/components/reports/entities.d.ts b/packages/desktop-client/src/components/reports/entities.d.ts index d5869883738..db251812ee7 100644 --- a/packages/desktop-client/src/components/reports/entities.d.ts +++ b/packages/desktop-client/src/components/reports/entities.d.ts @@ -31,19 +31,16 @@ export type MonthData = { totalTotals: number; }; -/* this will be used in a future PR - -export type GroupedEntity = { +type GroupedEntity = { id: string; name: string; date?: string; - monthData: Array; - categories?: Array; + monthData: MonthData[]; + categories?: ItemEntity[]; totalAssets: number; totalDebts: number; totalTotals: number; }; -*/ export type Month = { month: string; diff --git a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx index f377a48e92b..5599fb5c733 100644 --- a/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx +++ b/packages/desktop-client/src/components/reports/graphs/DonutGraph.tsx @@ -1,67 +1,87 @@ -import React from 'react'; +import React, { useState } from 'react'; -import { css } from 'glamor'; -import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts'; +import { PieChart, Pie, Cell, Sector, ResponsiveContainer } from 'recharts'; -import { amountToCurrency } from 'loot-core/src/shared/util'; - -import { theme } from '../../../style'; import { type CSSProperties } from '../../../style'; -import Text from '../../common/Text'; -import PrivacyFilter from '../../PrivacyFilter'; import Container from '../Container'; import { type DataEntity } from '../entities'; -import numberFormatterTooltip from '../numberFormatter'; - -type PayloadItem = { - name: string; - value: string; - color: string; - payload: { - date: string; - assets: number | string; - debt: number | string; - networth: number | string; - change: number | string; - fill: string; - }; -}; -type CustomTooltipProps = { - active?: boolean; - payload?: PayloadItem[]; - label?: string; -}; +const RADIAN = Math.PI / 180; +const ActiveShape = props => { + const { + cx, + cy, + midAngle, + innerRadius, + outerRadius, + startAngle, + endAngle, + fill, + payload, + percent, + value, + } = props; + const yAxis = payload.name ?? payload.date; + const sin = Math.sin(-RADIAN * midAngle); + const cos = Math.cos(-RADIAN * midAngle); + const sx = cx + (innerRadius - 10) * cos; + const sy = cy + (innerRadius - 10) * sin; + const mx = cx + (innerRadius - 30) * cos; + const my = cy + (innerRadius - 30) * sin; + const ex = cx + (cos >= 0 ? 1 : -1) * yAxis.length * 4; + const ey = cy + 8; + const textAnchor = cos <= 0 ? 'start' : 'end'; -const CustomTooltip = ({ active, payload, label }: CustomTooltipProps) => { - if (active && payload && payload.length) { - return ( - + + + + + {`${yAxis}`} + {`${value.toFixed(2)}`} + - - - {payload[0].name} - - - - - {amountToCurrency(payload[0].value)} - - - - - - ); - } + {`(${(percent * 100).toFixed(2)}%)`} + + + ); }; type DonutGraphProps = { @@ -90,6 +110,12 @@ function DonutGraph({ } }; + const [activeIndex, setActiveIndex] = useState(0); + + const onPieEnter = (_, index) => { + setActiveIndex(index); + }; + return ( {!compact && } - } - formatter={numberFormatterTooltip} - isAnimationActive={false} - /> getVal(val)} nameKey={yAxis} isAnimationActive={false} data={data[splitData]} innerRadius={Math.min(width, height) * 0.2} fill="#8884d8" + labelLine={false} + onMouseEnter={onPieEnter} > {data.legend.map((entry, index) => ( diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx index 4a5f019bcc8..307b630033f 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx @@ -342,7 +342,7 @@ export default function CustomReport() { months={months} /> ) : ( - + )} {(viewLegend || viewSummary) && data && ( diff --git a/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx b/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx index 77f5eccdafc..ba99695416d 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReportCard.jsx @@ -52,7 +52,7 @@ function CustomReportCard() { data={data} compact={true} groupBy={groupBy} - balanceTypeOp={'totalDebts'} + balanceTypeOp="totalDebts" style={{ height: 'auto', flex: 1 }} /> ) : ( diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.jsx b/packages/desktop-client/src/components/schedules/EditSchedule.jsx index adf731ec447..5beaa5968c8 100644 --- a/packages/desktop-client/src/components/schedules/EditSchedule.jsx +++ b/packages/desktop-client/src/components/schedules/EditSchedule.jsx @@ -514,8 +514,8 @@ export default function ScheduleDetails({ modalProps, actions, id }) { ) : ( + value={state.fields.amount} + onUpdate={value => dispatch({ type: 'set-field', field: 'amount', diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx index 149cb6fd188..e7c2574936c 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx @@ -118,7 +118,7 @@ function OverflowMenu({ onClose={() => setOpen(false)} > { + onMenuSelect={name => { onAction(name, schedule.id); setOpen(false); }} diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 92e2e833a47..ddf274b1b50 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -11,7 +11,6 @@ import React, { type ReactNode, type KeyboardEvent, type UIEvent, - type ReactElement, type Ref, } from 'react'; import { useStore } from 'react-redux'; @@ -847,17 +846,18 @@ type TableHandleRef = { type TableWithNavigatorProps = TableProps & { fields; }; -export const TableWithNavigator = forwardRef< - TableHandleRef, - TableWithNavigatorProps ->(({ fields, ...props }, ref) => { +export const TableWithNavigator = ({ + fields, + ...props +}: TableWithNavigatorProps) => { const navigator = useTableNavigator(props.items, fields); return ; -}); +}; type TableItem = { id: number | string }; type TableProps = { + tableRef?: Ref>; items: T[]; count?: number; headers?: ReactNode | TableHeaderProps['headers']; @@ -886,282 +886,276 @@ type TableProps = { saveScrollWidth?: (parent, child) => void; }; -export const Table: ( - props: TableProps & { ref?: Ref> }, -) => ReactElement = forwardRef( - ( - { - items, - count, - headers, - contentHeader, - loading, - rowHeight = ROW_HEIGHT, - backgroundColor = theme.tableHeaderBackground, - renderItem, - renderEmpty, - getItemKey, - loadMore, - style, - navigator, - onScroll, - version = 'v1', - allowPopupsEscape, - isSelected, - saveScrollWidth, - ...props - }, - ref, - ) => { - if (!navigator) { - navigator = { - onEdit: () => {}, - editingId: null, - focusedField: null, - getNavigatorProps: props => props, - }; - } - - const { onEdit, editingId, focusedField, getNavigatorProps } = navigator; - const list = useRef(null); - const listContainer = useRef(null); - const scrollContainer = useRef(null); - const initialScrollTo = useRef(null); - const listInitialized = useRef(false); - - useImperativeHandle(ref, () => ({ - scrollTo: (id, alignment = 'smart') => { - const index = items.findIndex(item => item.id === id); - if (index !== -1) { - if (!list.current) { - // If the table hasn't been laid out yet, we need to wait for - // that to happen before we can scroll to something - initialScrollTo.current = index; - } else { - list.current.scrollToItem(index, alignment); - } - } - }, - - scrollToTop: () => { - list.current?.scrollTo(0); - }, +export const Table = ({ + tableRef, + items, + count, + headers, + contentHeader, + loading, + rowHeight = ROW_HEIGHT, + backgroundColor = theme.tableHeaderBackground, + renderItem, + renderEmpty, + getItemKey, + loadMore, + style, + navigator, + onScroll, + version = 'v1', + allowPopupsEscape, + isSelected, + saveScrollWidth, + ...props +}: TableProps) => { + if (!navigator) { + navigator = { + onEdit: () => {}, + editingId: null, + focusedField: null, + getNavigatorProps: props => props, + }; + } - getScrolledItem: () => { - if (scrollContainer.current) { - const offset = scrollContainer.current.scrollTop; - const index = list.current.getStartIndexForOffset(offset); - return items[index].id; + const { onEdit, editingId, focusedField, getNavigatorProps } = navigator; + const list = useRef(null); + const listContainer = useRef(null); + const scrollContainer = useRef(null); + const initialScrollTo = useRef(null); + const listInitialized = useRef(false); + + useImperativeHandle(tableRef, () => ({ + scrollTo: (id, alignment = 'smart') => { + const index = items.findIndex(item => item.id === id); + if (index !== -1) { + if (!list.current) { + // If the table hasn't been laid out yet, we need to wait for + // that to happen before we can scroll to something + initialScrollTo.current = index; + } else { + list.current.scrollToItem(index, alignment); } - return 0; - }, - - setRowAnimation: flag => { - list.current?.setRowAnimation(flag); - }, + } + }, - edit(id, field, shouldScroll) { - onEdit(id, field); + scrollToTop: () => { + list.current?.scrollTo(0); + }, - if (id && shouldScroll) { - // @ts-expect-error this should not be possible - ref.scrollTo(id); - } - }, + getScrolledItem: () => { + if (scrollContainer.current) { + const offset = scrollContainer.current.scrollTop; + const index = list.current.getStartIndexForOffset(offset); + return items[index].id; + } + return 0; + }, - anchor() { - list.current?.anchor(); - }, + setRowAnimation: flag => { + list.current?.setRowAnimation(flag); + }, - unanchor() { - list.current?.unanchor(); - }, + edit(id, field, shouldScroll) { + onEdit(id, field); - isAnchored() { - return list.current && list.current.isAnchored(); - }, - })); - - useLayoutEffect(() => { - // We wait for the list to mount because AutoSizer needs to run - // before it's mounted - if (!listInitialized.current && listContainer.current) { - // Animation is on by default - list.current?.setRowAnimation(true); - listInitialized.current = true; + if (id && shouldScroll) { + // @ts-expect-error this should not be possible + ref.scrollTo(id); } + }, - if (scrollContainer.current && saveScrollWidth) { - saveScrollWidth( - scrollContainer.current.offsetParent - ? scrollContainer.current.offsetParent.clientWidth - : 0, - scrollContainer.current ? scrollContainer.current.clientWidth : 0, - ); - } - }); + anchor() { + list.current?.anchor(); + }, - function renderRow({ index, style, key }) { - const item = items[index]; - const editing = editingId === item.id; - const selected = isSelected && isSelected(item.id); - - const row = renderItem({ - item, - editing, - focusedField: editing && focusedField, - onEdit, - index, - position: style.top, - }); - - // TODO: Need to also apply zIndex if item is selected - // * Port over ListAnimation to Table - // * Move highlighted functionality into here - return ( - - {row} - - ); + unanchor() { + list.current?.unanchor(); + }, + + isAnchored() { + return list.current && list.current.isAnchored(); + }, + })); + + useLayoutEffect(() => { + // We wait for the list to mount because AutoSizer needs to run + // before it's mounted + if (!listInitialized.current && listContainer.current) { + // Animation is on by default + list.current?.setRowAnimation(true); + listInitialized.current = true; } - function getScrollOffset(height, index) { - return ( - index * (rowHeight - 1) + - (rowHeight - 1) / 2 - - height / 2 + - (rowHeight - 1) * 2 + if (scrollContainer.current && saveScrollWidth) { + saveScrollWidth( + scrollContainer.current.offsetParent + ? scrollContainer.current.offsetParent.clientWidth + : 0, + scrollContainer.current ? scrollContainer.current.clientWidth : 0, ); } + }); - function onItemsRendered({ overscanStartIndex, overscanStopIndex }) { - if (loadMore && overscanStopIndex > items.length - 100) { - loadMore(); - } - } + function renderRow({ index, style, key }) { + const item = items[index]; + const editing = editingId === item.id; + const selected = isSelected && isSelected(item.id); - function getEmptyContent(empty) { - if (empty == null) { - return null; - } else if (typeof empty === 'function') { - return empty(); - } + const row = renderItem({ + item, + editing, + focusedField: editing && focusedField, + onEdit, + index, + position: style.top, + }); - return ( - - {empty} - - ); - } + // TODO: Need to also apply zIndex if item is selected + // * Port over ListAnimation to Table + // * Move highlighted functionality into here + return ( + + {row} + + ); + } - if (loading) { - return ( - - - - ); + function getScrollOffset(height, index) { + return ( + index * (rowHeight - 1) + + (rowHeight - 1) / 2 - + height / 2 + + (rowHeight - 1) * 2 + ); + } + + function onItemsRendered({ overscanStartIndex, overscanStopIndex }) { + if (loadMore && overscanStopIndex > items.length - 100) { + loadMore(); } + } - const isEmpty = (count || items.length) === 0; + function getEmptyContent(empty) { + if (empty == null) { + return null; + } else if (typeof empty === 'function') { + return empty(); + } return ( - {headers && ( - - )} - - {isEmpty ? ( - getEmptyContent(renderEmpty) - ) : ( - - {({ width, height }) => { - if (width === 0 || height === 0) { - return null; - } + {empty} + + ); + } - return ( - - - items[index].id) - } - indexForKey={key => - items.findIndex(item => item.id === key) - } - initialScrollOffset={ - initialScrollTo.current - ? getScrollOffset(height, initialScrollTo.current) - : 0 - } - overscanCount={5} - onItemsRendered={onItemsRendered} - onScroll={onScroll} - /> - - - ); - }} - - )} - + if (loading) { + return ( + + ); - }, -); + } + + const isEmpty = (count || items.length) === 0; + + return ( + + {headers && ( + + )} + + {isEmpty ? ( + getEmptyContent(renderEmpty) + ) : ( + + {({ width, height }) => { + if (width === 0 || height === 0) { + return null; + } + + return ( + + + items[index].id) + } + indexForKey={key => + items.findIndex(item => item.id === key) + } + initialScrollOffset={ + initialScrollTo.current + ? getScrollOffset(height, initialScrollTo.current) + : 0 + } + overscanCount={5} + onItemsRendered={onItemsRendered} + onScroll={onScroll} + /> + + + ); + }} + + )} + + + ); +}; export type TableNavigator = { onEdit: (id: T['id'], field?: string) => void; diff --git a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx index 369ea54da8f..7763f1bf639 100644 --- a/packages/desktop-client/src/components/transactions/MobileTransaction.jsx +++ b/packages/desktop-client/src/components/transactions/MobileTransaction.jsx @@ -1,10 +1,10 @@ import React, { - PureComponent, - Component, forwardRef, useEffect, useState, useRef, + memo, + useMemo, } from 'react'; import { useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -21,7 +21,6 @@ import { isValid as isValidDate, } from 'date-fns'; import { css } from 'glamor'; -import memoizeOne from 'memoize-one'; import q, { runQuery } from 'loot-core/src/client/query-helpers'; import { send } from 'loot-core/src/platform/client/fetch'; @@ -32,6 +31,9 @@ import { ungroupTransactions, updateTransaction, realizeTempTransactions, + splitTransaction, + addSplitTransaction, + deleteTransaction, } from 'loot-core/src/shared/transactions'; import { titleFirst, @@ -47,12 +49,17 @@ import { useActions } from '../../hooks/useActions'; import useCategories from '../../hooks/useCategories'; import useNavigate from '../../hooks/useNavigate'; import { useSetThemeColor } from '../../hooks/useSetThemeColor'; -import SvgAdd from '../../icons/v1/Add'; -import SvgTrash from '../../icons/v1/Trash'; +import { + SingleActiveEditFormProvider, + useSingleActiveEditForm, +} from '../../hooks/useSingleActiveEditForm'; +import Split from '../../icons/v0/Split'; +import Add from '../../icons/v1/Add'; +import Trash from '../../icons/v1/Trash'; import ArrowsSynchronize from '../../icons/v2/ArrowsSynchronize'; import CheckCircle1 from '../../icons/v2/CheckCircle1'; import Lock from '../../icons/v2/LockClosed'; -import SvgPencilWriteAlternate from '../../icons/v2/PencilWriteAlternate'; +import PencilWriteAlternate from '../../icons/v2/PencilWriteAlternate'; import { styles, theme } from '../../style'; import Button from '../common/Button'; import Text from '../common/Text'; @@ -67,11 +74,13 @@ import { } from '../mobile/MobileForms'; import MobileBackButton from '../MobileBackButton'; import { Page } from '../Page'; +import { AmountInput } from '../util/AmountInput'; const zIndices = { SECTION_HEADING: 10 }; -const getPayeesById = memoizeOne(payees => groupById(payees)); -const getAccountsById = memoizeOne(accounts => groupById(accounts)); +function getFieldName(transactionId, field) { + return `${field}-${transactionId}`; +} function getDescriptionPretty(transaction, payee, transferAcct) { const { amount } = transaction; @@ -167,80 +176,352 @@ function Status({ status }) { ); } -class TransactionEditInner extends PureComponent { - constructor(props) { - super(props); - this.state = { - transactions: props.transactions, - editingChild: null, - }; - } +function Footer({ + transactions, + adding, + onAdd, + onSave, + onSplit, + onAddSplit, + onEmptySplitFound, +}) { + const [transaction, ...childTransactions] = transactions; + const onClickRemainingSplit = () => { + if (childTransactions.length === 0) { + onSplit(transaction.id); + } else { + const emptySplitTransaction = childTransactions.find(t => t.amount === 0); + if (!emptySplitTransaction) { + onAddSplit(transaction.id); + } else { + onEmptySplitFound?.(emptySplitTransaction.id); + } + } + }; + + return ( + + {transaction.error?.type === 'SplitTransactionError' ? ( + e.preventDefault()} + > + + + Amount left:{' '} + {integerToCurrency( + transaction.amount > 0 + ? transaction.error.difference + : -transaction.error.difference, + )} + + + ) : adding ? ( + e.preventDefault()} + > + + + Add transaction + + + ) : ( + e.preventDefault()} + > + + + Save changes + + + )} + + ); +} - serializeTransactions = memoizeOne(transactions => { - return transactions.map(t => - serializeTransaction(t, this.props.dateFormat), +const ChildTransactionEdit = forwardRef( + ( + { + transaction, + amountSign, + getCategory, + getPrettyPayee, + isOffBudget, + isBudgetTransfer, + onClick, + onEdit, + onDelete, + }, + ref, + ) => { + const { editingField, onRequestActiveEdit, onClearActiveEdit } = + useSingleActiveEditForm(); + return ( + + + + + onClick(transaction.id, 'payee')} + data-testid={`payee-field-${transaction.id}`} + /> + + + + + onRequestActiveEdit(getFieldName(transaction.id, 'amount')) + } + onUpdate={value => { + const amount = integerToAmount(value); + if (transaction.amount !== amount) { + onEdit(transaction, 'amount', amount); + } else { + onClearActiveEdit(); + } + }} + /> + + + + + + onClick(transaction.id, 'category')} + data-testid={`category-field-${transaction.id}`} + /> + + + + + + onRequestActiveEdit(getFieldName(transaction.id, 'notes')) + } + onUpdate={value => onEdit(transaction, 'notes', value)} + /> + + + + onDelete(transaction.id)} + onPointerDown={e => e.preventDefault()} + style={{ + height: 40, + borderWidth: 0, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + marginTop: 10, + backgroundColor: 'transparent', + }} + type="bare" + > + + + Delete split + + + + ); - }); + }, +); + +const TransactionEditInner = memo(function TransactionEditInner({ + adding, + accounts, + categories, + payees, + dateFormat, + transactions: unserializedTransactions, + navigate, + pushModal, + ...props +}) { + const { editingField, onRequestActiveEdit, onClearActiveEdit } = + useSingleActiveEditForm(); + const [totalAmountFocused, setTotalAmountFocused] = useState(false); + const childTransactionElementRefMap = useRef({}); + + const payeesById = useMemo(() => groupById(payees), [payees]); + const accountsById = useMemo(() => groupById(accounts), [accounts]); + + const getAccount = trans => { + return trans?.account && accountsById?.[trans.account]; + }; - componentDidMount() { - if (this.props.adding) { - this.amount.focus(); - } - } + const getPayee = trans => { + return trans?.payee && payeesById?.[trans.payee]; + }; - componentWillUnmount() { - document - .querySelector('meta[name="theme-color"]') - .setAttribute('content', '#ffffff'); - } + const getTransferAcct = trans => { + const payee = trans && getPayee(trans); + return payee?.transfer_acct && accountsById?.[payee.transfer_acct]; + }; + + const getPrettyPayee = trans => { + const transPayee = trans && getPayee(trans); + const transTransferAcct = trans && getTransferAcct(trans); + return getDescriptionPretty(trans, transPayee, transTransferAcct); + }; + + const isBudgetTransfer = trans => { + const transferAcct = trans && getTransferAcct(trans); + return transferAcct && !transferAcct.offbudget; + }; + + const getCategory = (trans, isOffBudget) => { + return isOffBudget + ? 'Off Budget' + : isBudgetTransfer(trans) + ? 'Transfer' + : lookupName(categories, trans.category); + }; - openChildEdit = child => { - this.setState({ editingChild: child.id }); + const onTotalAmountEdit = () => { + onRequestActiveEdit?.(getFieldName(transaction.id, 'amount'), () => { + setTotalAmountFocused(true); + return () => setTotalAmountFocused(false); + }); }; - onAdd = () => { - this.onSave(); + useEffect(() => { + if (adding) { + onTotalAmountEdit(); + } + }, []); + + const onTotalAmountUpdate = value => { + if (transaction.amount !== value) { + onEdit(transaction, 'amount', value.toString()); + } else { + onClearActiveEdit(); + } }; - onSave = async () => { + const onSave = async () => { + const [transaction] = unserializedTransactions; + const onConfirmSave = async () => { - let { transactions } = this.state; - const [transaction, ..._childTransactions] = transactions; const { account: accountId } = transaction; - const account = getAccountsById(this.props.accounts)[accountId]; + const account = accountsById[accountId]; - if (transactions.find(t => t.account == null)) { + if (unserializedTransactions.find(t => t.account == null)) { // Ignore transactions if any of them don't have an account // TODO: Should we display validation error? return; } - // Since we don't own the state, we have to handle the case where - // the user saves while editing an input. We won't have the - // updated value so we "apply" a queued change. Maybe there's a - // better way to do this (lift the state?) - if (this._queuedChange) { - const [transaction, name, value] = this._queuedChange; - transactions = await this.onEdit(transaction, name, value); + let transactionsToSave = unserializedTransactions; + if (adding) { + transactionsToSave = realizeTempTransactions(unserializedTransactions); } - if (this.props.adding) { - transactions = realizeTempTransactions(transactions); - } - - this.props.onSave(transactions); - this.props.navigate(`/accounts/${account.id}`, { replace: true }); + props.onSave(transactionsToSave); + navigate(`/accounts/${account.id}`, { replace: true }); }; - const { transactions } = this.state; - const [transaction] = transactions; - if (transaction.reconciled) { // On mobile any save gives the warning. // On the web only certain changes trigger a warning. // Should we bring that here as well? Or does the nature of the editing form // make this more appropriate? - this.props.pushModal('confirm-transaction-edit', { + pushModal('confirm-transaction-edit', { onConfirm: onConfirmSave, confirmReason: 'editReconciled', }); @@ -249,72 +530,58 @@ class TransactionEditInner extends PureComponent { } }; - onSaveChild = childTransaction => { - this.setState({ editingChild: null }); + const onAdd = () => { + onSave(); }; - onEdit = async (transaction, name, value) => { - const { transactions } = this.state; - - let newTransaction = { ...transaction, [name]: value }; - if (this.props.onEdit) { - newTransaction = await this.props.onEdit(newTransaction); - } - - const { data: newTransactions } = updateTransaction( - transactions, - deserializeTransaction(newTransaction, null, this.props.dateFormat), - ); - - this._queuedChange = null; - this.setState({ transactions: newTransactions }); - return newTransactions; - }; - - onQueueChange = (transaction, name, value) => { - // This is an ugly hack to solve the problem that input's blur - // events are not fired when unmounting. If the user has focused - // an input and swipes back, it should still save, but because the - // blur event is not fired we need to manually track the latest - // change and apply it ourselves when unmounting - this._queuedChange = [transaction, name, value]; + const onEdit = async (transaction, name, value) => { + const newTransaction = { ...transaction, [name]: value }; + await props.onEdit(newTransaction); + onClearActiveEdit(); }; - onClick = (transactionId, name) => { - const { dateFormat } = this.props; - - this.props.pushModal('edit-field', { - name, - onSubmit: (name, value) => { - const { transactions } = this.state; - const transaction = transactions.find(t => t.id === transactionId); - // This is a deficiency of this API, need to fix. It - // assumes that it receives a serialized transaction, - // but we only have access to the raw transaction - this.onEdit(serializeTransaction(transaction, dateFormat), name, value); - }, + const onClick = (transactionId, name) => { + onRequestActiveEdit?.(getFieldName(transaction.id, 'payee'), () => { + pushModal('edit-field', { + name, + onSubmit: (name, value) => { + const transaction = unserializedTransactions.find( + t => t.id === transactionId, + ); + // This is a deficiency of this API, need to fix. It + // assumes that it receives a serialized transaction, + // but we only have access to the raw transaction + onEdit(serializeTransaction(transaction, dateFormat), name, value); + }, + onClose: () => { + onClearActiveEdit(); + }, + }); }); }; - onDelete = () => { + const onDelete = id => { + const [transaction, ..._childTransactions] = unserializedTransactions; + const onConfirmDelete = () => { - this.props.onDelete(); + props.onDelete(id); + + if (transaction.id !== id) { + // Only a child transaction was deleted. + onClearActiveEdit(); + return; + } - const { transactions } = this.state; - const [transaction, ..._childTransactions] = transactions; const { account: accountId } = transaction; if (accountId) { - this.props.navigate(`/accounts/${accountId}`, { replace: true }); + navigate(`/accounts/${accountId}`, { replace: true }); } else { - this.props.navigate(-1); + navigate(-1); } }; - const { transactions } = this.state; - const [transaction] = transactions; - if (transaction.reconciled) { - this.props.pushModal('confirm-transaction-edit', { + pushModal('confirm-transaction-edit', { onConfirm: onConfirmDelete, confirmReason: 'deleteReconciled', }); @@ -323,316 +590,322 @@ class TransactionEditInner extends PureComponent { } }; - render() { - const { adding, categories, accounts, payees } = this.props; - const transactions = this.serializeTransactions( - this.state.transactions || [], - ); - const [transaction, ..._childTransactions] = transactions; - const { payee: payeeId, category, account: accountId } = transaction; - - // Child transactions should always default to the signage - // of the parent transaction - // const forcedSign = transaction.amount < 0 ? 'negative' : 'positive'; - - const account = getAccountsById(accounts)[accountId]; - const isOffBudget = account && !!account.offbudget; - const payee = payees && payeeId && getPayeesById(payees)[payeeId]; - const transferAcct = - payee && - payee.transfer_acct && - getAccountsById(accounts)[payee.transfer_acct]; - const isBudgetTransfer = transferAcct && !transferAcct.offbudget; - const descriptionPretty = getDescriptionPretty( - transaction, - payee, - transferAcct, - ); + const scrollChildTransactionIntoView = id => { + const childTransactionEditElement = + childTransactionElementRefMap.current?.[id]; + childTransactionEditElement?.scrollIntoView({ + behavior: 'smooth', + }); + }; - const transactionDate = parseDate( - transaction.date, - this.props.dateFormat, - new Date(), - ); - const dateDefaultValue = monthUtils.dayFromDate(transactionDate); + const onAddSplit = id => { + props.onAddSplit(id); + }; - return ( - } - footer={ - - {adding ? ( - this.onAdd()}> - - - Add transaction - - - ) : ( - this.onSave()}> - - - Save changes - - - )} - - } - padding={0} - > - - { + props.onSplit(id); + }; + + const onEmptySplitFound = id => { + scrollChildTransactionIntoView(id); + }; + + const transactions = useMemo( + () => + unserializedTransactions.map(t => serializeTransaction(t, dateFormat)) || + [], + [unserializedTransactions, dateFormat], + ); + + const [transaction, ...childTransactions] = transactions; + + useEffect(() => { + const noAmountTransaction = childTransactions.find(t => t.amount === 0); + if (noAmountTransaction) { + scrollChildTransactionIntoView(noAmountTransaction.id); + } + }, [childTransactions]); + + // Child transactions should always default to the signage + // of the parent transaction + const childAmountSign = transaction.amount <= 0 ? '-' : '+'; + + const account = getAccount(transaction); + const isOffBudget = account && !!account.offbudget; + const title = getDescriptionPretty( + transaction, + getPayee(transaction), + getTransferAcct(transaction), + ); + + const transactionDate = parseDate(transaction.date, dateFormat, new Date()); + const dateDefaultValue = monthUtils.dayFromDate(transactionDate); + + return ( + } + footer={ + + } + padding={0} + > + + + + - - (this.amount = el)} - value={transaction.amount} - zeroIsNegative={true} - onBlur={value => - this.onEdit(transaction, 'amount', value.toString()) - } - onChange={value => - this.onQueueChange(transaction, 'amount', value) - } - style={{ transform: [] }} - focusedStyle={{ - width: 'auto', - padding: '5px', - paddingLeft: '20px', - paddingRight: '20px', - minWidth: 120, - transform: [{ translateY: -0.5 }], - }} - textStyle={{ fontSize: 30, textAlign: 'center' }} - /> - + textStyle={{ fontSize: 30, textAlign: 'center' }} + /> + + + + + onClick(transaction.id, 'payee')} + data-testid="payee-field" + /> + + {!transaction.is_parent && ( - + this.onClick(transaction.id, 'payee')} - data-testid="payee-field" + style={{ + ...((isOffBudget || isBudgetTransfer(transaction)) && { + fontStyle: 'italic', + color: theme.pageTextSubdued, + fontWeight: 300, + }), + }} + value={getCategory(transaction, isOffBudget)} + disabled={ + (editingField && + editingField !== getFieldName(transaction.id, 'category')) || + isOffBudget || + isBudgetTransfer(transaction) + } + onClick={() => onClick(transaction.id, 'category')} + data-testid="category-field" /> + )} + + {childTransactions.map(childTrans => ( + { + childTransactionElementRefMap.current = { + ...childTransactionElementRefMap.current, + [childTrans.id]: r, + }; + }} + isOffBudget={isOffBudget} + getCategory={getCategory} + getPrettyPayee={getPrettyPayee} + isBudgetTransfer={isBudgetTransfer} + onEdit={onEdit} + onClick={onClick} + onDelete={onDelete} + /> + ))} - - - {!transaction.is_parent ? ( - + onSplit(transaction.id)} + type="bare" + > + + - // Split - // - // } - onClick={() => this.onClick(transaction.id, 'category')} - data-testid="category-field" - /> - ) : ( - - Split transaction editing is not supported on mobile at this - time. + > + Split - )} - - - - - this.onClick(transaction.id, 'account')} - data-testid="account-field" - /> - - - - - - - this.onEdit( - transaction, - 'date', - formatDate(parseISO(value), this.props.dateFormat), - ) - } - onChange={e => - this.onQueueChange( - transaction, - 'date', - formatDate(parseISO(e.target.value), this.props.dateFormat), - ) - } - /> - - {transaction.reconciled ? ( - - - - - ) : ( - - - - this.onEdit(transaction, 'cleared', checked) - } - style={{ - margin: 'auto', - width: 22, - height: 22, - }} - /> - - )} + + )} + + + + onClick(transaction.id, 'account')} + data-testid="account-field" + /> + - - + + + this.onEdit(transaction, 'notes', value)} - onChange={e => - this.onQueueChange(transaction, 'notes', e.target.value) + type="date" + disabled={ + editingField && + editingField !== getFieldName(transaction.id, 'date') + } + required + style={{ color: theme.tableText, minWidth: '150px' }} + defaultValue={dateDefaultValue} + onFocus={() => + onRequestActiveEdit(getFieldName(transaction.id, 'date')) + } + onUpdate={value => + onEdit( + transaction, + 'date', + formatDate(parseISO(value), dateFormat), + ) } /> - - {!adding && ( - - this.onDelete()} + {transaction.reconciled ? ( + + + - - - Delete transaction - - + /> + + ) : ( + + + onEdit(transaction, 'cleared', checked)} + style={{ + margin: 'auto', + width: 22, + height: 22, + }} + /> )} - - ); - } -} + + + + { + onRequestActiveEdit(getFieldName(transaction.id, 'notes')); + }} + onUpdate={value => onEdit(transaction, 'notes', value)} + /> + + + {!adding && ( + + onDelete(transaction.id)} + style={{ + height: 40, + borderWidth: 0, + marginLeft: styles.mobileEditingPadding, + marginRight: styles.mobileEditingPadding, + marginTop: 10, + backgroundColor: 'transparent', + }} + type="bare" + > + + + Delete transaction + + + + )} + + + ); +}); function isTemporary(transaction) { return transaction.id.indexOf('temp') === 0; @@ -654,10 +927,10 @@ function TransactionEditUnconnected(props) { const { categories, accounts, payees, lastTransaction, dateFormat } = props; const { id: accountId, transactionId } = useParams(); const navigate = useNavigate(); - const [fetchedTransactions, setFetchedTransactions] = useState(null); - let transactions = []; - let adding = false; - let deleted = false; + const [transactions, setTransactions] = useState([]); + const [fetchedTransactions, setFetchedTransactions] = useState([]); + const adding = useRef(false); + const deleted = useRef(false); useSetThemeColor(theme.mobileViewTheme); useEffect(() => { @@ -667,56 +940,62 @@ function TransactionEditUnconnected(props) { props.getPayees(); async function fetchTransaction() { - let transactions = []; - if (transactionId) { - // Query for the transaction based on the ID with grouped splits. - // - // This means if the transaction in question is a split transaction, its - // subtransactions will be returned in the `substransactions` property on - // the parent transaction. - // - // The edit item components expect to work with a flat array of - // transactions when handling splits, so we call ungroupTransactions to - // flatten parent and children into one array. - const { data } = await runQuery( - q('transactions') - .filter({ id: transactionId }) - .select('*') - .options({ splits: 'grouped' }), - ); - transactions = ungroupTransactions(data); - setFetchedTransactions(transactions); - } + // Query for the transaction based on the ID with grouped splits. + // + // This means if the transaction in question is a split transaction, its + // subtransactions will be returned in the `substransactions` property on + // the parent transaction. + // + // The edit item components expect to work with a flat array of + // transactions when handling splits, so we call ungroupTransactions to + // flatten parent and children into one array. + const { data } = await runQuery( + q('transactions') + .filter({ id: transactionId }) + .select('*') + .options({ splits: 'grouped' }), + ); + setFetchedTransactions(ungroupTransactions(data)); + } + if (transactionId) { + fetchTransaction(); + } else { + adding.current = true; } - fetchTransaction(); }, [transactionId]); + useEffect(() => { + setTransactions(fetchedTransactions); + }, [fetchedTransactions]); + + useEffect(() => { + if (adding.current) { + setTransactions( + makeTemporaryTransactions( + accountId || lastTransaction?.account || null, + lastTransaction?.date, + ), + ); + } + }, [adding.current, accountId, lastTransaction]); + if ( categories.length === 0 || accounts.length === 0 || - (transactionId && !fetchedTransactions) + transactions.length === 0 ) { return null; } - if (!transactionId) { - transactions = makeTemporaryTransactions( - accountId || (lastTransaction && lastTransaction.account) || null, - lastTransaction && lastTransaction.date, - ); - adding = true; - } else { - transactions = fetchedTransactions; - } - const onEdit = async transaction => { + let newTransaction = transaction; // Run the rules to auto-fill in any data. Right now we only do // this on new transactions because that's how desktop works. if (isTemporary(transaction)) { const afterRules = await send('rules-run', { transaction }); const diff = getChangedValues(transaction, afterRules); - const newTransaction = { ...transaction }; + newTransaction = { ...transaction }; if (diff) { Object.keys(diff).forEach(field => { if (newTransaction[field] == null) { @@ -724,18 +1003,21 @@ function TransactionEditUnconnected(props) { } }); } - return newTransaction; } - return transaction; + const { data: newTransactions } = updateTransaction( + transactions, + deserializeTransaction(newTransaction, null, dateFormat), + ); + setTransactions(newTransactions); }; const onSave = async newTransactions => { - if (deleted) { + if (deleted.current) { return; } - const changes = diffItems(transactions || [], newTransactions); + const changes = diffItems(fetchedTransactions || [], newTransactions); if ( changes.added.length > 0 || changes.updated.length > 0 || @@ -755,24 +1037,40 @@ function TransactionEditUnconnected(props) { // } } - if (adding) { + if (adding.current) { // The first one is always the "parent" and the only one we care // about props.setLastTransaction(newTransactions[0]); } }; - const onDelete = async () => { - if (adding) { + const onDelete = async id => { + const changes = deleteTransaction(transactions, id); + + if (adding.current) { // Adding a new transactions, this disables saving when the component unmounts - deleted = true; + deleted.current = true; } else { - const changes = { deleted: transactions }; - const _remoteUpdates = await send('transactions-batch-update', changes); + const _remoteUpdates = await send('transactions-batch-update', { + deleted: changes.diff.deleted, + }); + // if (onTransactionsChange) { // onTransactionsChange({ ...changes, updated: remoteUpdates }); // } } + + setTransactions(changes.data); + }; + + const onAddSplit = id => { + const changes = addSplitTransaction(transactions, id); + setTransactions(changes.data); + }; + + const onSplit = id => { + const changes = splitTransaction(transactions, id); + setTransactions(changes.data); }; return ( @@ -784,21 +1082,18 @@ function TransactionEditUnconnected(props) { > } - renderChildEdit={props => {}} dateFormat={dateFormat} - // TODO: was this a mistake in the original code? - // onTapField={this.onTapField} onEdit={onEdit} onSave={onSave} onDelete={onDelete} + onSplit={onSplit} + onAddSplit={onAddSplit} /> ); @@ -815,188 +1110,198 @@ export const TransactionEdit = props => { const actions = useActions(); return ( - + + + ); }; -class Transaction extends PureComponent { - render() { - const { - transaction, - accounts, - categories, - payees, - showCategory, - added, - onSelect, - style, - } = this.props; - const { - id, - payee: payeeId, - amount: originalAmount, - category, - cleared, - is_parent, - notes, - schedule, - } = transaction; - - let amount = originalAmount; - if (isPreviewId(id)) { - amount = getScheduledAmount(amount); - } +const Transaction = memo(function Transaction({ + transaction, + accounts, + categories, + payees, + showCategory, + added, + onSelect, + style, +}) { + const accountsById = useMemo(() => groupById(accounts), [accounts]); + const payeesById = useMemo(() => groupById(payees), [payees]); + + const { + id, + payee: payeeId, + amount: originalAmount, + category: categoryId, + cleared, + is_parent: isParent, + notes, + schedule, + } = transaction; + + let amount = originalAmount; + if (isPreviewId(id)) { + amount = getScheduledAmount(amount); + } - const categoryName = lookupName(categories, category); + const categoryName = lookupName(categories, categoryId); - const payee = payees && payeeId && getPayeesById(payees)[payeeId]; - const transferAcct = - payee && - payee.transfer_acct && - getAccountsById(accounts)[payee.transfer_acct]; + const payee = payeesById && payeeId && payeesById[payeeId]; + const transferAcct = + payee && payee.transfer_acct && accountsById[payee.transfer_acct]; - const prettyDescription = getDescriptionPretty( - transaction, - payee, - transferAcct, - ); - const prettyCategory = transferAcct - ? 'Transfer' - : is_parent - ? 'Split' - : categoryName; - - const isPreview = isPreviewId(id); - const isReconciled = transaction.reconciled; - const textStyle = isPreview && { - fontStyle: 'italic', - color: theme.pageTextLight, - }; + const prettyDescription = getDescriptionPretty( + transaction, + payee, + transferAcct, + ); + const prettyCategory = transferAcct + ? 'Transfer' + : isParent + ? 'Split' + : categoryName; + + const isPreview = isPreviewId(id); + const isReconciled = transaction.reconciled; + const textStyle = isPreview && { + fontStyle: 'italic', + color: theme.pageTextLight, + }; - return ( - onSelect(transaction)} + return ( + onSelect(transaction)} + style={{ + backgroundColor: theme.tableBackground, + border: 'none', + width: '100%', + }} + > + - - - - {schedule && ( - + + {schedule && ( + + )} + + {prettyDescription || 'Empty'} + + + {isPreview ? ( + + ) : ( + + {isReconciled ? ( + + ) : ( + )} - - {prettyDescription || 'Empty'} - + {showCategory && ( + + {prettyCategory || 'Uncategorized'} + + )} - {isPreview ? ( - - ) : ( - - {isReconciled ? ( - - ) : ( - - )} - {showCategory && ( - - {prettyCategory || 'Uncategorized'} - - )} - - )} - - - {integerToCurrency(amount)} - - - - ); - } -} + )} + + + {integerToCurrency(amount)} + + + + ); +}); -export class TransactionList extends Component { - makeData = memoizeOne(transactions => { +export function TransactionList({ + accounts, + categories, + payees, + transactions, + showCategory, + isNew, + onSelect, + scrollProps = {}, + onLoadMore, +}) { + const sections = useMemo(() => { // Group by date. We can assume transactions is ordered const sections = []; transactions.forEach(transaction => { @@ -1026,78 +1331,70 @@ export class TransactionList extends Component { } }); return sections; - }); + }, [transactions]); - render() { - const { transactions, scrollProps = {}, onLoadMore } = this.props; - - const sections = this.makeData(transactions); - - return ( - <> - {scrollProps.ListHeaderComponent} - - {sections.length === 0 ? ( - - - - No transactions - - - - ) : null} - {sections.map(section => { - return ( - - {monthUtils.format(section.date, 'MMMM dd, yyyy')} - - } - key={section.id} + return ( + <> + {scrollProps.ListHeaderComponent} + + {sections.length === 0 ? ( + + + - {section.data.map((transaction, index, transactions) => { - return ( - - - - ); - })} - - ); - })} - - > - ); - } + No transactions + + + + ) : null} + {sections.map(section => { + return ( + {monthUtils.format(section.date, 'MMMM dd, yyyy')} + } + key={section.id} + > + {section.data.map((transaction, index, transactions) => { + return ( + + + + ); + })} + + ); + })} + + > + ); } function ListBox(props) { @@ -1203,7 +1500,6 @@ function Option({ isLast, item, state }) { const { optionProps, isSelected } = useOption({ key: item.key }, state, ref); // Determine whether we should show a keyboard - // focus ring for accessibility const { isFocusVisible, focusProps } = useFocusRing(); return ( diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index a1009c1aaca..5c04a9289e7 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -1722,7 +1722,7 @@ function TransactionTableInner({ > ; - initialValue: number; + value: number; zeroSign?: '-' | '+'; - onChange?: (value: number) => void; - onBlur?: () => void; + onChange?: (value: string) => void; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; + onUpdate?: (amount: number) => void; style?: CSSProperties; textStyle?: CSSProperties; focused?: boolean; + disabled?: boolean; }; export function AmountInput({ id, inputRef, - initialValue, + value: initialValue, zeroSign = '-', // + or - - onChange, + onFocus, onBlur, + onChange, + onUpdate, style, textStyle, focused, + disabled = false, + ...props }: AmountInputProps) { const format = useFormat(); - const [negative, setNegative] = useState( - (initialValue === 0 && zeroSign === '-') || initialValue < 0, - ); + const negative = (initialValue === 0 && zeroSign === '-') || initialValue < 0; - const initialValueAbsolute = format(Math.abs(initialValue), 'financial'); + const initialValueAbsolute = format(Math.abs(initialValue || 0), 'financial'); const [value, setValue] = useState(initialValueAbsolute); useEffect(() => setValue(initialValueAbsolute), [initialValueAbsolute]); const buttonRef = useRef(); + const ref = useRef(); + const mergedRef = useMergedRefs(inputRef, ref); + + useEffect(() => { + if (focused) { + ref.current?.focus(); + } + }, [focused]); function onSwitch() { - setNegative(!negative); - fireChange(value, !negative); + fireUpdate(!negative); } - function fireChange(val, neg) { + function getAmount(negate) { const valueOrInitial = Math.abs( - amountToInteger(evalArithmetic(val, initialValueAbsolute)), + amountToInteger(evalArithmetic(value, initialValueAbsolute)), ); - const amount = neg ? valueOrInitial * -1 : valueOrInitial; - - onChange?.(amount); + return negate ? valueOrInitial * -1 : valueOrInitial; } - function onInputAmountChange(value) { - setValue(value ? value : ''); + function onInputTextChange(val) { + setValue(val ? val : ''); + onChange?.(val); } - const ref = useRef(); - const mergedRef = useMergedRefs(inputRef, ref); - - useEffect(() => { - if (focused) { - ref.current?.focus(); - } - }, [focused]); + function fireUpdate(negate) { + onUpdate?.(getAmount(negate)); + } function onInputAmountBlur(e) { - fireChange(value, negative); if (!ref.current?.contains(e.relatedTarget)) { - onBlur?.(); + fireUpdate(negative); } + onBlur?.(e); } return ( @@ -88,6 +100,7 @@ export function AmountInput({ leftContent={ } value={value} + disabled={disabled} focused={focused} style={{ flex: 1, alignItems: 'stretch', ...style }} inputStyle={{ paddingLeft: 0, ...textStyle }} onKeyUp={e => { if (e.key === 'Enter') { - fireChange(value, negative); - onBlur?.(); + fireUpdate(negative); } }} - onUpdate={onInputAmountChange} + onUpdate={onInputTextChange} onBlur={onInputAmountBlur} + onFocus={onFocus} /> ); } @@ -124,8 +138,8 @@ export function BetweenAmountInput({ defaultValue, onChange }) { return ( { + value={num1} + onUpdate={value => { setNum1(value); onChange({ num1: value, num2 }); }} @@ -133,8 +147,8 @@ export function BetweenAmountInput({ defaultValue, onChange }) { /> and { + value={num2} + onUpdate={value => { setNum2(value); onChange({ num1, num2: value }); }} diff --git a/packages/desktop-client/src/components/util/GenericInput.jsx b/packages/desktop-client/src/components/util/GenericInput.jsx index e62807b60e2..dfa2d95ddcb 100644 --- a/packages/desktop-client/src/components/util/GenericInput.jsx +++ b/packages/desktop-client/src/components/util/GenericInput.jsx @@ -136,7 +136,7 @@ export default function GenericInput({ onChange(e.target.value)} onBlur={e => onChange(e.target.value)} /> diff --git a/packages/desktop-client/src/hooks/useSingleActiveEditForm.tsx b/packages/desktop-client/src/hooks/useSingleActiveEditForm.tsx new file mode 100644 index 00000000000..662c3c3fcbb --- /dev/null +++ b/packages/desktop-client/src/hooks/useSingleActiveEditForm.tsx @@ -0,0 +1,125 @@ +import React, { + type ReactNode, + createContext, + useContext, + useState, + useRef, + useEffect, +} from 'react'; + +import usePrevious from './usePrevious'; +import useStableCallback from './useStableCallback'; + +type ActiveEditCleanup = () => void; +type ActiveEditAction = () => void | ActiveEditCleanup; + +type SingleActiveEditFormContextValue = { + formName: string; + editingField: string; + onRequestActiveEdit: ( + field: string, + action?: ActiveEditAction, + clearActiveEditDelayMs?: number, + ) => void; + onClearActiveEdit: (delayMs?: number) => void; +}; + +const SingleActiveEditFormContext = createContext< + SingleActiveEditFormContextValue | undefined +>(undefined); + +type SingleActiveEditFormProviderProps = { + formName: string; + children: ReactNode; +}; + +export function SingleActiveEditFormProvider({ + formName, + children, +}: SingleActiveEditFormProviderProps) { + const [editingField, setEditingField] = useState(null); + const prevEditingField = usePrevious(editingField); + const actionRef = useRef(null); + const cleanupRef = useRef(null); + + useEffect(() => { + if (prevEditingField != null && prevEditingField !== editingField) { + runCleanup(); + } else if (prevEditingField == null && editingField !== null) { + runAction(); + } + }, [editingField]); + + const runAction = () => { + cleanupRef.current = actionRef.current?.(); + }; + + const runCleanup = () => { + const editCleanup = cleanupRef.current; + if (typeof editCleanup === 'function') { + editCleanup?.(); + } + cleanupRef.current = null; + }; + + const onClearActiveEdit = (delayMs?: number) => { + setTimeout(() => setEditingField(null), delayMs); + }; + + const onRequestActiveEdit = useStableCallback( + ( + field: string, + action: ActiveEditAction, + options: { + clearActiveEditDelayMs?: number; + }, + ) => { + if (editingField === field) { + // Already active. + return; + } + + if (editingField) { + onClearActiveEdit(options?.clearActiveEditDelayMs); + } else { + actionRef.current = action; + setEditingField(field); + } + }, + ); + + return ( + + {children} + + ); +} + +type UseSingleActiveEditFormResult = { + formName: SingleActiveEditFormContextValue['formName']; + editingField?: SingleActiveEditFormContextValue['editingField']; + onRequestActiveEdit: SingleActiveEditFormContextValue['onRequestActiveEdit']; + onClearActiveEdit: SingleActiveEditFormContextValue['onClearActiveEdit']; +}; + +export function useSingleActiveEditForm(): UseSingleActiveEditFormResult | null { + const context = useContext(SingleActiveEditFormContext); + + if (!context) { + return null; + } + + return { + formName: context.formName, + editingField: context.editingField, + onRequestActiveEdit: context.onRequestActiveEdit, + onClearActiveEdit: context.onClearActiveEdit, + }; +} diff --git a/packages/eslint-plugin-actual/lib/rules/prefer-if-statement.js b/packages/eslint-plugin-actual/lib/rules/prefer-if-statement.js index 386db0590ef..f3584602d2c 100644 --- a/packages/eslint-plugin-actual/lib/rules/prefer-if-statement.js +++ b/packages/eslint-plugin-actual/lib/rules/prefer-if-statement.js @@ -4,7 +4,8 @@ // Rule Definition //------------------------------------------------------------------------------ -let suggestion = 'Consider using an if statement or optional chaining instead.'; +const suggestion = + 'Consider using an if statement or optional chaining instead.'; /** @type {import('eslint').Rule.RuleModule} */ module.exports = { @@ -23,7 +24,7 @@ module.exports = { }, create(context) { - let sourceCode = context.getSourceCode(); + const sourceCode = context.getSourceCode(); //---------------------------------------------------------------------- // Helpers diff --git a/packages/eslint-plugin-actual/lib/rules/typography.js b/packages/eslint-plugin-actual/lib/rules/typography.js index cd32cfc1831..6ee049327db 100644 --- a/packages/eslint-plugin-actual/lib/rules/typography.js +++ b/packages/eslint-plugin-actual/lib/rules/typography.js @@ -34,7 +34,7 @@ module.exports = { let rawText = context.getSourceCode().getText(node); if (strip) rawText = rawText.slice(1, -1); for (const match of rawText.matchAll(/['"]/g)) { - let index = node.range[0] + match.index + (strip ? 1 : 0); + const index = node.range[0] + match.index + (strip ? 1 : 0); context.report({ node, loc: { diff --git a/packages/loot-core/migrations/1632571489012_remove_cache.js b/packages/loot-core/migrations/1632571489012_remove_cache.js index 2f3764c47de..bbd3e213849 100644 --- a/packages/loot-core/migrations/1632571489012_remove_cache.js +++ b/packages/loot-core/migrations/1632571489012_remove_cache.js @@ -31,14 +31,14 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL); `); // Migrate budget amounts and carryover - let budget = db.runQuery( + const budget = db.runQuery( `SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!budget-%'`, [], true, ); db.transaction(() => { budget.forEach(monthBudget => { - let match = monthBudget.name.match( + const match = monthBudget.name.match( /^(budget-report|budget)(\d+)!budget-(.+)$/, ); if (match == null) { @@ -46,24 +46,25 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL); return; } - let type = match[1]; - let month = match[2].slice(0, 4) + '-' + match[2].slice(4); - let dbmonth = parseInt(match[2]); - let cat = match[3]; + const type = match[1]; + const month = match[2].slice(0, 4) + '-' + match[2].slice(4); + const dbmonth = parseInt(match[2]); + const cat = match[3]; let amount = parseInt(getValue(monthBudget)); if (isNaN(amount)) { amount = 0; } - let sheetName = monthBudget.name.split('!')[0]; - let carryover = db.runQuery( + const sheetName = monthBudget.name.split('!')[0]; + const carryover = db.runQuery( 'SELECT * FROM spreadsheet_cells WHERE name = ?', [`${sheetName}!carryover-${cat}`], true, ); - let table = type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets'; + const table = + type === 'budget-report' ? 'reflect_budgets' : 'zero_budgets'; db.runQuery( `INSERT INTO ${table} (id, month, category, amount, carryover) VALUES (?, ?, ?, ?, ?)`, [ @@ -78,16 +79,16 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL); }); // Migrate buffers - let buffers = db.runQuery( + const buffers = db.runQuery( `SELECT * FROM spreadsheet_cells WHERE name LIKE 'budget%!buffered'`, [], true, ); db.transaction(() => { buffers.forEach(buffer => { - let match = buffer.name.match(/^budget(\d+)!buffered$/); + const match = buffer.name.match(/^budget(\d+)!buffered$/); if (match) { - let month = match[1].slice(0, 4) + '-' + match[1].slice(4); + const month = match[1].slice(0, 4) + '-' + match[1].slice(4); let amount = parseInt(getValue(buffer)); if (isNaN(amount)) { amount = 0; @@ -102,15 +103,15 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL); }); // Migrate notes - let notes = db.runQuery( + const notes = db.runQuery( `SELECT * FROM spreadsheet_cells WHERE name LIKE 'notes!%'`, [], true, ); - let parseNote = str => { + const parseNote = str => { try { - let value = JSON.parse(str); + const value = JSON.parse(str); return value && value !== '' ? value : null; } catch (e) { return null; @@ -119,9 +120,9 @@ CREATE TABLE kvcache_key (id INTEGER PRIMARY KEY, key REAL); db.transaction(() => { notes.forEach(note => { - let parsed = parseNote(getValue(note)); + const parsed = parseNote(getValue(note)); if (parsed) { - let [, id] = note.name.split('!'); + const [, id] = note.name.split('!'); db.runQuery(`INSERT INTO notes (id, note) VALUES (?, ?)`, [id, parsed]); } }); diff --git a/packages/loot-core/src/client/actions/modals.ts b/packages/loot-core/src/client/actions/modals.ts index 7abf5e78a0e..879092f454e 100644 --- a/packages/loot-core/src/client/actions/modals.ts +++ b/packages/loot-core/src/client/actions/modals.ts @@ -48,3 +48,7 @@ export function popModal(): PopModalAction { export function closeModal(): CloseModalAction { return { type: constants.CLOSE_MODAL }; } + +export function collapseModals(rootModalName: string) { + return { type: constants.COLLAPSE_MODALS, rootModalName }; +} diff --git a/packages/loot-core/src/client/constants.ts b/packages/loot-core/src/client/constants.ts index 95a7e8fa9dc..c107dea1c82 100644 --- a/packages/loot-core/src/client/constants.ts +++ b/packages/loot-core/src/client/constants.ts @@ -17,6 +17,7 @@ export const SET_APP_STATE = 'SET_APP_STATE'; export const PUSH_MODAL = 'PUSH_MODAL'; export const REPLACE_MODAL = 'REPLACE_MODAL'; export const CLOSE_MODAL = 'CLOSE_MODAL'; +export const COLLAPSE_MODALS = 'COLLAPSE_MODALS'; export const POP_MODAL = 'POP_MODAL'; export const ADD_NOTIFICATION = 'ADD_NOTIFICATION'; export const REMOVE_NOTIFICATION = 'REMOVE_NOTIFICATION'; diff --git a/packages/loot-core/src/client/reducers/modals.ts b/packages/loot-core/src/client/reducers/modals.ts index eeb743869d6..57d32784bce 100644 --- a/packages/loot-core/src/client/reducers/modals.ts +++ b/packages/loot-core/src/client/reducers/modals.ts @@ -22,7 +22,18 @@ function update(state = initialState, action: Action): ModalsState { case constants.POP_MODAL: return { ...state, modalStack: state.modalStack.slice(0, -1) }; case constants.CLOSE_MODAL: - return { ...state, modalStack: [] }; + return { + ...state, + modalStack: [], + }; + case constants.COLLAPSE_MODALS: + const idx = state.modalStack.findIndex( + m => m.name === action.rootModalName, + ); + return { + ...state, + modalStack: idx < 0 ? state.modalStack : state.modalStack.slice(0, idx), + }; case constants.SET_APP_STATE: if ('loadingText' in action.state) { return { diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts index 1c12c19f09d..36771e2e984 100644 --- a/packages/loot-core/src/client/state-types/modals.d.ts +++ b/packages/loot-core/src/client/state-types/modals.d.ts @@ -1,5 +1,10 @@ import { type File } from '../../types/file'; -import type { AccountEntity, GoCardlessToken } from '../../types/models'; +import type { + AccountEntity, + CategoryEntity, + CategoryGroupEntity, + GoCardlessToken, +} from '../../types/models'; import type { RuleEntity } from '../../types/models/rule'; import type { EmptyObject, StripNever } from '../../types/util'; import type * as constants from '../constants'; @@ -90,6 +95,7 @@ type FinanceModals = { 'edit-field': { name: string; onSubmit: (name: string, value: string) => void; + onClose: () => void; }; 'budget-summary': { @@ -104,6 +110,27 @@ type FinanceModals = { 'schedule-posts-offline-notification': null; 'switch-budget-type': { onSwitch: () => void }; + 'category-menu': { + category: CategoryEntity; + onSave: (category: CategoryEntity) => void; + onEditNotes: (id: string) => void; + onSaveNotes: (id: string, notes: string) => void; + onDelete: (categoryId: string) => void; + onClose?: () => void; + }; + 'category-group-menu': { + group: CategoryGroupEntity; + onSave: (group: CategoryGroupEntity) => void; + onAddCategory: (groupId: string, isIncome: boolean) => void; + onEditNotes: (id: string) => void; + onDelete: (groupId: string) => void; + onClose?: () => void; + }; + notes: { + id: string; + name: string; + onSave: (id: string, notes: string) => void; + }; }; export type PushModalAction = { @@ -124,11 +151,17 @@ export type CloseModalAction = { type: typeof constants.CLOSE_MODAL; }; +export type CollapseModalsAction = { + type: typeof constants.COLLAPSE_MODALS; + rootModalName: string; +}; + export type ModalsActions = | PushModalAction | ReplaceModalAction | PopModalAction - | CloseModalAction; + | CloseModalAction + | CollapseModalsAction; export type ModalsState = { modalStack: Modal[]; diff --git a/packages/loot-core/src/server/api.ts b/packages/loot-core/src/server/api.ts index 34a9730575b..b87d4b14bd2 100644 --- a/packages/loot-core/src/server/api.ts +++ b/packages/loot-core/src/server/api.ts @@ -443,7 +443,7 @@ handlers['api/transaction-update'] = withMutation(async function ({ return []; } - const { diff } = updateTransaction(transactions, fields); + const { diff } = updateTransaction(transactions, { id, ...fields }); return handlers['transactions-batch-update'](diff); }); diff --git a/packages/loot-core/src/server/sync/sync.property.test.ts b/packages/loot-core/src/server/sync/sync.property.test.ts index 346eb3f16ce..d748f9d5168 100644 --- a/packages/loot-core/src/server/sync/sync.property.test.ts +++ b/packages/loot-core/src/server/sync/sync.property.test.ts @@ -111,7 +111,7 @@ function makeGen>({ value, timestamp: jsc.integer(1000, 10000).smap( x => { - let clientId; + let clientId: string; switch (jsc.random(0, 1)) { case 0: clientId = clientId1; diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts index cb1f0c8cfe5..1f0d276e496 100644 --- a/packages/loot-core/src/types/server-handlers.d.ts +++ b/packages/loot-core/src/types/server-handlers.d.ts @@ -332,7 +332,10 @@ export interface ServerHandlers { 'close-budget': () => Promise<'ok'>; - 'delete-budget': (arg: { id; cloudFileId? }) => Promise<'ok'>; + 'delete-budget': (arg: { + id?: string; + cloudFileId?: string; + }) => Promise<'ok'>; 'create-budget': (arg: { budgetName?; diff --git a/packages/node-libofx/index.js b/packages/node-libofx/index.js index 50b2a203e7e..8c8939325bf 100644 --- a/packages/node-libofx/index.js +++ b/packages/node-libofx/index.js @@ -5,7 +5,7 @@ let _libofxPromise; let _libofx; let ffi; -let parser = { +const parser = { ctx: null, transactions: [], @@ -61,7 +61,7 @@ export async function initModule() { export function getOFXTransactions(data) { ffi.parse_data(parser.ctx, data); - let transactions = parser.transactions; + const transactions = parser.transactions; parser.reset(); return transactions; } diff --git a/upcoming-release-notes/1964.md b/upcoming-release-notes/1964.md new file mode 100644 index 00000000000..16d45249fb9 --- /dev/null +++ b/upcoming-release-notes/1964.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [joel-jeremy] +--- + +Category and group menu/modal in the mobile budget page to manage categories/groups and their notes. diff --git a/upcoming-release-notes/2068.md b/upcoming-release-notes/2068.md new file mode 100644 index 00000000000..a4afd516438 --- /dev/null +++ b/upcoming-release-notes/2068.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [joel-jeremy] +--- + +Mobile split transactions diff --git a/upcoming-release-notes/2071.md b/upcoming-release-notes/2071.md new file mode 100644 index 00000000000..e443cc99adc --- /dev/null +++ b/upcoming-release-notes/2071.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Fixing TypeScript issues when enabling `strictFunctionTypes` (pt.4). diff --git a/upcoming-release-notes/2085.md b/upcoming-release-notes/2085.md new file mode 100644 index 00000000000..5f3fefcdb98 --- /dev/null +++ b/upcoming-release-notes/2085.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [carkom] +--- + +Realign and fix header/totals row for table graph in custom reports diff --git a/upcoming-release-notes/2098.md b/upcoming-release-notes/2098.md new file mode 100644 index 00000000000..fcfa29d54d6 --- /dev/null +++ b/upcoming-release-notes/2098.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [carkom] +--- + +Changing the view and functions for donut graph in custom reports. diff --git a/upcoming-release-notes/2112.md b/upcoming-release-notes/2112.md new file mode 100644 index 00000000000..ed16eda983c --- /dev/null +++ b/upcoming-release-notes/2112.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +TypeScript: moving `DeleteFile` component to TS diff --git a/upcoming-release-notes/2113.md b/upcoming-release-notes/2113.md new file mode 100644 index 00000000000..b78ebabc777 --- /dev/null +++ b/upcoming-release-notes/2113.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [joel-jeremy] +--- + +Enable prefer-const ESLint rule project-wide diff --git a/upcoming-release-notes/2127.md b/upcoming-release-notes/2127.md new file mode 100644 index 00000000000..d217cfebb58 --- /dev/null +++ b/upcoming-release-notes/2127.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [mk-french] +--- + +Fix update transaction API bug