diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 8f4fadba8363db7cf2a811cab79c938aa14386a9..5da4049a2bb22409c4345dc9959a639b3e2e2bbe 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'; @@ -241,7 +244,7 @@ export default function Modals() { <SingleInput modalProps={modalProps} title="New Category" - inputPlaceholder="Name" + inputPlaceholder="Category name" buttonText="Add" onValidate={options.onValidate} onSubmit={options.onSubmit} @@ -253,7 +256,7 @@ export default function Modals() { <SingleInput modalProps={modalProps} title="New Category Group" - inputPlaceholder="Name" + inputPlaceholder="Category group name" buttonText="Add" onValidate={options.onValidate} onSubmit={options.onSubmit} @@ -326,6 +329,45 @@ export default function Modals() { /> ); + case 'category-menu': + return ( + <CategoryMenu + key={name} + modalProps={modalProps} + categoryId={options.categoryId} + onSave={options.onSave} + onEditNotes={options.onEditNotes} + onDelete={options.onDelete} + onClose={options.onClose} + /> + ); + + case 'category-group-menu': + return ( + <CategoryGroupMenu + key={name} + modalProps={modalProps} + groupId={options.groupId} + onSave={options.onSave} + onAddCategory={options.onAddCategory} + onEditNotes={options.onEditNotes} + onSaveNotes={options.onSaveNotes} + onDelete={options.onDelete} + onClose={options.onClose} + /> + ); + + case 'notes': + return ( + <Notes + key={name} + modalProps={modalProps} + id={options.id} + name={options.name} + onSave={options.onSave} + /> + ); + 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 0000000000000000000000000000000000000000..818d77a0188e4438c0fc1e75c49aab6726c1ac31 --- /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<HTMLTextAreaElement>(); + + useEffect(() => { + if (focused && editable) { + textAreaRef.current.focus(); + } + }, [focused, editable]); + + return editable ? ( + <textarea + ref={textAreaRef} + className={`${css({ + border: '1px solid ' + theme.buttonNormalBorder, + padding: 7, + ...(!isNarrowWidth && { minWidth: 350, minHeight: 120 }), + outline: 'none', + backgroundColor: theme.tableBackground, + color: theme.tableText, + ...getStyle?.(editable), + })}`} + value={notes || ''} + onChange={e => _onChange(e.target.value)} + onBlur={e => onBlur?.(e.target.value)} + placeholder="Notes (markdown supported)" + /> + ) : ( + <Text {...markdownStyles} style={{ ...getStyle?.(editable) }}> + <ReactMarkdown remarkPlugins={remarkPlugins} linkTarget="_blank"> + {notes} + </ReactMarkdown> + </Text> + ); +} diff --git a/packages/desktop-client/src/components/NotesButton.tsx b/packages/desktop-client/src/components/NotesButton.tsx index bbb1e14b67eef8508196b55c5721386ef7ae81d7..3e43538993eb8509f87dac6e08e8b7d524c08020 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<string>(defaultNotes); - const inputRef = createRef<HTMLTextAreaElement>(); - - useEffect(() => { - if (editable) { - inputRef.current.focus(); - } - }, [inputRef, editable]); - return ( <Tooltip position={position} onClose={() => onClose(notes)}> - {editable ? ( - <textarea - ref={inputRef} - className={`${css({ - border: '1px solid ' + theme.buttonNormalBorder, - padding: 7, - minWidth: 350, - minHeight: 120, - outline: 'none', - backgroundColor: theme.tableBackground, - color: theme.tableText, - })}`} - value={notes || ''} - onChange={e => setNotes(e.target.value)} - placeholder="Notes (markdown supported)" - /> - ) : ( - <Text {...markdownStyles}> - <ReactMarkdown remarkPlugins={remarkPlugins} linkTarget="_blank"> - {notes} - </ReactMarkdown> - </Text> - )} + <Notes + notes={notes} + editable={editable} + focused={editable} + onChange={setNotes} + /> </Tooltip> ); } diff --git a/packages/desktop-client/src/components/budget/MobileBudget.jsx b/packages/desktop-client/src/components/budget/MobileBudget.jsx index 8c3e7327c65db14682d8dbb9fd47739ff7656d7c..0408a57560ddda2a1b533d1bfd95f9a918ae8601 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} /> )} </SyncRefresh> diff --git a/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx b/packages/desktop-client/src/components/budget/MobileBudgetTable.jsx index eb7905bd8dfc5179fc30ddf194c83c7de17b8233..d326e87ce3a86919a19c0d73ee376d123ee1cd1d 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'; @@ -243,71 +242,25 @@ const ExpenseCategory = memo(function ExpenseCategory({ style, month, editMode, - isEditing, onEdit, isEditingBudget, onEditBudget, - onSave, - onDelete, - isBudgetActionMenuOpen, - onOpenBudgetActionMenu, + isBalanceActionMenuOpen, + onOpenBalanceActionMenu, onBudgetAction, show3Cols, showBudgetedCol, }) { const opacity = blank ? 0 : 1; - const showEditables = editMode || isEditing; - - const [categoryName, setCategoryName] = useState(category.name); - const [isHidden, setIsHidden] = useState(category.hidden); - - const tooltip = useTooltip(); const balanceTooltip = useTooltip(); useEffect(() => { - 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 = ( <ListItem style={{ - backgroundColor: isEditingBudget - ? theme.tableTextEditing - : 'transparent', + backgroundColor: 'transparent', borderBottomWidth: 0, borderTopWidth: index > 0 ? 1 : 0, - opacity: isHidden ? 0.5 : undefined, + opacity: !!category.hidden ? 0.5 : undefined, ...style, }} data-testid="row" innerRef={listItemRef} > - <View - style={{ - ...(!showEditables && { display: 'none' }), - flexDirection: 'row', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - height: ROW_HEIGHT, - }} - > - <InputWithContent - focused={isEditing} - inputRef={inputRef} - rightContent={ - <> - <Button - type="bare" - aria-label="Menu" - style={{ padding: 10 }} - {...tooltip.getOpenEvents()} - > - <DotsHorizontalTriple width={12} height={12} /> - </Button> - {tooltip.isOpen && ( - <Tooltip - position="bottom-stretch" - offset={1} - style={{ padding: 0 }} - onClose={() => { - tooltip.close(); - inputRef.current?.focus(); - }} - > - <Menu - onMenuSelect={onMenuSelect} - items={[ - { - name: 'toggle-visibility', - text: isHidden ? 'Show' : 'Hide', - }, - { - name: 'delete', - text: 'Delete', - }, - ]} - /> - </Tooltip> - )} - </> - } - style={{ width: '100%' }} - placeholder="Category Name" - value={categoryName} - onUpdate={setCategoryName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - </View> - <View - role="button" - style={{ ...(showEditables && { display: 'none' }), flex: 1 }} - > + <View role="button" style={{ flex: 1 }}> <Text style={{ ...styles.smallText, ...styles.underlinedText, ...styles.lineClamp(2), }} - onPointerUp={() => onEdit?.(category.id)} + onClick={() => onEdit?.(category.id)} data-testid="category-name" > {category.name} @@ -411,7 +297,6 @@ const ExpenseCategory = memo(function ExpenseCategory({ </View> <View style={{ - ...(showEditables && { display: 'none' }), justifyContent: 'center', alignItems: 'center', flexDirection: 'row', @@ -463,7 +348,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ > <span role="button" - onPointerUp={() => onOpenBudgetActionMenu?.(category.id)} + onPointerUp={() => onOpenBalanceActionMenu?.(category.id)} onPointerDown={e => e.preventDefault()} > <BalanceWithCarryover @@ -485,7 +370,7 @@ const ExpenseCategory = memo(function ExpenseCategory({ monthIndex={monthUtils.getMonthIndex(month)} onBudgetAction={_onBudgetAction} onClose={() => { - 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 = ( <ListItem @@ -611,90 +445,20 @@ const ExpenseGroupTotals = memo(function ExpenseGroupTotals({ flexDirection: 'row', alignItems: 'center', backgroundColor: theme.tableRowHeaderBackground, - opacity: isHidden ? 0.5 : undefined, + opacity: !!group.hidden ? 0.5 : undefined, }} data-testid="totals" innerRef={listItemRef} > - <View - style={{ - ...(!showEditables && { display: 'none' }), - flexDirection: 'row', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - height: ROW_HEIGHT, - }} - > - <InputWithContent - focused={isEditing} - inputRef={inputRef} - rightContent={ - <> - <Button - type="bare" - aria-label="Menu" - style={{ padding: 10 }} - {...tooltip.getOpenEvents()} - > - <DotsHorizontalTriple width={12} height={12} /> - </Button> - {tooltip.isOpen && ( - <Tooltip - position="bottom-stretch" - offset={1} - style={{ padding: 0 }} - onClose={() => { - tooltip.close(); - inputRef.current?.focus(); - }} - > - <Menu - onMenuSelect={onMenuSelect} - items={[ - { - name: 'add-category', - text: 'Add category', - }, - { - name: 'toggle-visibility', - text: isHidden ? 'Show' : 'Hide', - }, - { - name: 'delete', - text: 'Delete', - }, - ]} - /> - </Tooltip> - )} - </> - } - style={{ width: '100%' }} - placeholder="Category Group Name" - value={groupName} - onUpdate={setGroupName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - </View> - <View - role="button" - style={{ ...(showEditables && { display: 'none' }), flex: 1 }} - > + <View role="button" style={{ flex: 1 }}> <Text - tabIndex={-1} style={{ ...styles.smallText, ...styles.underlinedText, ...styles.lineClamp(2), fontWeight: '500', }} - onPointerUp={() => onEdit?.(group.id)} + onClick={() => onEdit?.(group.id)} data-testid="name" > {group.name} @@ -702,7 +466,6 @@ const ExpenseGroupTotals = memo(function ExpenseGroupTotals({ </View> <View style={{ - ...(showEditables && { display: 'none' }), flexDirection: 'row', justifyContent: 'center', alignItems: 'center', @@ -804,60 +567,10 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ budgeted, balance, style, - onAddCategory, - onSave, - onDelete, editMode, - isEditing, onEdit, }) { - const [groupName, setGroupName] = useState(group.name); - const [isHidden, setIsHidden] = useState(group.hidden); - const showEditables = editMode || isEditing; - - 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(); return ( <ListItem @@ -866,81 +579,14 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ alignItems: 'center', padding: 10, backgroundColor: theme.tableRowHeaderBackground, - opacity: isHidden ? 0.5 : undefined, + opacity: !!group.hidden ? 0.5 : undefined, ...style, }} innerRef={listItemRef} > - <View - style={{ - ...(!showEditables && { display: 'none' }), - flexDirection: 'row', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - height: ROW_HEIGHT, - }} - > - <InputWithContent - focused={isEditing} - inputRef={inputRef} - rightContent={ - <> - <Button - type="bare" - aria-label="Menu" - style={{ padding: 10 }} - {...tooltip.getOpenEvents()} - > - <DotsHorizontalTriple width={12} height={12} /> - </Button> - {tooltip.isOpen && ( - <Tooltip - position="bottom-stretch" - offset={1} - style={{ padding: 0 }} - onClose={() => { - tooltip.close(); - inputRef.current?.focus(); - }} - > - <Menu - onMenuSelect={onMenuSelect} - items={[ - { - name: 'add-category', - text: 'Add category', - }, - { - name: 'toggle-visibility', - text: isHidden ? 'Show' : 'Hide', - }, - { - name: 'delete', - text: 'Delete', - }, - ]} - /> - </Tooltip> - )} - </> - } - style={{ width: '100%' }} - placeholder="Category Group Name" - value={groupName} - onUpdate={setGroupName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - </View> <View role="button" style={{ - ...(showEditables && { display: 'none' }), flex: 1, justifyContent: 'center', alignItems: 'flex-start', @@ -954,7 +600,7 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ ...styles.lineClamp(2), fontWeight: '500', }} - onPointerUp={() => onEdit?.(group.id)} + onClick={() => onEdit?.(group.id)} data-testid="name" > {group.name} @@ -963,7 +609,6 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ {budgeted && ( <View style={{ - ...(showEditables && { display: 'none' }), justifyContent: 'center', alignItems: 'flex-end', width: 90, @@ -983,7 +628,6 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ )} <View style={{ - ...(showEditables && { display: 'none' }), justifyContent: 'center', alignItems: 'flex-end', width: 90, @@ -1005,64 +649,19 @@ const IncomeGroupTotals = memo(function IncomeGroupTotals({ }); const IncomeCategory = memo(function IncomeCategory({ + index, category, budgeted, balance, month, style, - onSave, - onDelete, editMode, - isEditing, onEdit, onBudgetAction, isEditingBudget, onEditBudget, }) { - const [categoryName, setCategoryName] = useState(category.name); - const [isHidden, setIsHidden] = useState(category.hidden); - const showEditables = editMode || isEditing; - - const tooltip = useTooltip(); - - useEffect(() => { - 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 ( <ListItem @@ -1071,77 +670,16 @@ const IncomeCategory = memo(function IncomeCategory({ alignItems: 'center', padding: 10, backgroundColor: 'transparent', - opacity: isHidden ? 0.5 : undefined, + borderBottomWidth: 0, + borderTopWidth: index > 0 ? 1 : 0, + opacity: !!category.hidden ? 0.5 : undefined, ...style, }} innerRef={listItemRef} > - <View - style={{ - ...(!showEditables && { display: 'none' }), - flexDirection: 'row', - flex: 1, - justifyContent: 'center', - alignItems: 'center', - height: ROW_HEIGHT, - }} - > - <InputWithContent - focused={isEditing} - inputRef={inputRef} - rightContent={ - <> - <Button - type="bare" - aria-label="Menu" - style={{ padding: 10 }} - {...tooltip.getOpenEvents()} - > - <DotsHorizontalTriple width={12} height={12} /> - </Button> - {tooltip.isOpen && ( - <Tooltip - position="bottom-stretch" - offset={1} - style={{ padding: 0 }} - onClose={() => { - tooltip.close(); - inputRef.current?.focus(); - }} - > - <Menu - onMenuSelect={onMenuSelect} - items={[ - { - name: 'toggle-visibility', - text: isHidden ? 'Show' : 'Hide', - }, - { - name: 'delete', - text: 'Delete', - }, - ]} - /> - </Tooltip> - )} - </> - } - style={{ width: '100%' }} - placeholder="Category Name" - value={categoryName} - onUpdate={setCategoryName} - onEnter={onSubmit} - onBlur={e => { - if (!listItemRef.current?.contains(e.relatedTarget)) { - onSubmit(); - } - }} - /> - </View> <View role="button" style={{ - ...(showEditables && { display: 'none' }), flex: 1, justifyContent: 'center', alignItems: 'flex-start', @@ -1149,13 +687,12 @@ const IncomeCategory = memo(function IncomeCategory({ }} > <Text - tabIndex={-1} style={{ ...styles.smallText, ...styles.underlinedText, ...styles.lineClamp(2), }} - onPointerUp={() => onEdit?.(category.id)} + onClick={() => onEdit?.(category.id)} data-testid="name" > {category.name} @@ -1164,7 +701,6 @@ const IncomeCategory = memo(function IncomeCategory({ {budgeted && ( <View style={{ - ...(showEditables && { display: 'none' }), justifyContent: 'center', alignItems: 'flex-end', width: 90, @@ -1196,7 +732,6 @@ const IncomeCategory = memo(function IncomeCategory({ )} <View style={{ - ...(showEditables && { display: 'none' }), justifyContent: 'center', alignItems: 'flex-end', width: 90, @@ -1259,23 +794,17 @@ const ExpenseGroup = memo(function ExpenseGroup({ type, group, editMode, - editingGroupId, onEditGroup, - editingCategoryId, onEditCategory, editingBudgetCategoryId, onEditCategoryBudget, - openBudgetActionMenuId, - onOpenBudgetActionMenu, + openBalanceActionMenuId, + onOpenBalanceActionMenu, // gestures, month, - onSaveCategory, - onDeleteCategory, // onReorderCategory, // onReorderGroup, onAddCategory, - onSave, - onDelete, onBudgetAction, showBudgetedCol, show3Cols, @@ -1338,9 +867,6 @@ const ExpenseGroup = memo(function ExpenseGroup({ show3Cols={show3Cols} editMode={editMode} onAddCategory={onAddCategory} - onSave={onSave} - onDelete={onDelete} - isEditing={editingGroupId === group.id} onEdit={onEditGroup} // onReorderCategory={onReorderCategory} /> @@ -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 ( <ExpenseCategory key={category.id} + index={index} show3Cols={show3Cols} type={type} - index={index} category={category} goal={ type === 'report' @@ -1384,23 +910,20 @@ const ExpenseGroup = memo(function ExpenseGroup({ ? reportBudget.catCarryover(category.id) : rolloverBudget.catCarryover(category.id) } + style={{ + backgroundColor: theme.tableBackground, + }} showBudgetedCol={showBudgetedCol} editMode={editMode} - isEditing={isEditingCategory} onEdit={onEditCategory} isEditingBudget={isEditingCategoryBudget} onEditBudget={onEditCategoryBudget} - isBudgetActionMenuOpen={isBudgetActionMenuOpen} - onOpenBudgetActionMenu={onOpenBudgetActionMenu} + isBalanceActionMenuOpen={isBalanceActionMenuOpen} + onOpenBalanceActionMenu={onOpenBalanceActionMenu} // gestures={gestures} month={month} - onSave={onSaveCategory} - onDelete={onDeleteCategory} // onReorder={onReorderCategory} onBudgetAction={onBudgetAction} - style={{ - backgroundColor: theme.tableBackground, - }} /> ); })} @@ -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 ( <IncomeCategory key={category.id} + index={index} category={category} month={month} type={type} @@ -1484,15 +999,11 @@ function IncomeGroup({ ? reportBudget.catSumAmount(category.id) : rolloverBudget.catSumAmount(category.id) } - index={index} - onSave={onSaveCategory} - onDelete={onDeleteCategory} - editMode={editMode} - isEditing={editingCategoryId === category.id} - onEdit={onEditCategory} style={{ backgroundColor: theme.tableBackground, }} + editMode={editMode} + onEdit={onEditCategory} onBudgetAction={onBudgetAction} isEditingBudget={editingBudgetCategoryId === category.id} onEditBudget={onEditCategoryBudget} @@ -1507,14 +1018,12 @@ function IncomeGroup({ function BudgetGroups({ type, categoryGroups, - editingGroupId, onEditGroup, - editingCategoryId, onEditCategory, editingBudgetCategoryId, onEditCategoryBudget, - openBudgetActionMenuId, - onOpenBudgetActionMenu, + openBalanceActionMenuId, + onOpenBalanceActionMenu, editMode, gestures, month, @@ -1522,14 +1031,13 @@ function BudgetGroups({ onDeleteCategory, onAddCategory, onAddGroup, - onSaveGroup, - onDeleteGroup, onReorderCategory, onReorderGroup, onBudgetAction, showBudgetedCol, show3Cols, showHiddenCategories, + pushModal, }) { const separateGroups = memoizeOne(groups => { 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', }} > - <Button onPointerUp={onAddGroup} style={{ fontSize: 12, margin: 10 }}> + <Button onClick={onAddGroup} style={{ fontSize: 12, margin: 10 }}> Add Group </Button> </View> @@ -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} /> )} </View> ); } -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 ? ( - <BudgetMenu + <BudgetPageMenu onEditMode={onEditMode} onToggleHiddenCategories={onToggleHiddenCategories} onSwitchBudgetType={_onSwitchBudgetType} @@ -1925,14 +1389,12 @@ export function BudgetTable(props) { // 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} @@ -1941,7 +1403,9 @@ export function BudgetTable(props) { onDeleteGroup={onDeleteGroup} onReorderCategory={onReorderCategory} onReorderGroup={onReorderGroup} + onOpenMonthActionMenu={onOpenMonthActionMenu} onBudgetAction={onBudgetAction} + pushModal={pushModal} /> </View> ) : ( @@ -1954,7 +1418,7 @@ export function BudgetTable(props) { // scrollRef, // onScroll // }) => ( - <View> + <View data-testid="budget-table"> <BudgetGroups type={type} categoryGroups={categoryGroups} @@ -1963,12 +1427,12 @@ export function BudgetTable(props) { showHiddenCategories={showHiddenCategories} // gestures={gestures} editMode={editMode} - editingGroupId={editingGroupId} onEditGroup={onEditGroup} - editingCategoryId={editingCategoryId} onEditCategory={onEditCategory} editingBudgetCategoryId={editingBudgetCategoryId} onEditCategoryBudget={onEditCategoryBudget} + openBalanceActionMenuId={openBalanceActionMenuId} + onOpenBalanceActionMenu={onOpenBalanceActionMenu} onSaveCategory={onSaveCategory} onDeleteCategory={onDeleteCategory} onAddCategory={onAddCategory} @@ -1977,7 +1441,9 @@ export function BudgetTable(props) { onDeleteGroup={onDeleteGroup} onReorderCategory={onReorderCategory} onReorderGroup={onReorderGroup} + onOpenMonthActionMenu={onOpenMonthActionMenu} onBudgetAction={onBudgetAction} + pushModal={pushModal} /> </View> @@ -1990,7 +1456,7 @@ export function BudgetTable(props) { ); } -function BudgetMenu({ +function BudgetPageMenu({ onEditMode, onToggleHiddenCategories, onSwitchBudgetType, @@ -2044,7 +1510,8 @@ function BudgetMenu({ <Menu onMenuSelect={onMenuSelect} items={[ - { name: 'edit-mode', text: 'Edit mode' }, + // Removing for now until we work on mobile category drag and drop. + // { name: 'edit-mode', text: 'Edit mode' }, { name: 'toggle-hidden-categories', text: 'Toggle hidden categories', diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx index eb5efa66c66e3563843758784d30f944a2d0e33c..2f0d002f71c966d66dec4052450f85eceb84ffdb 100644 --- a/packages/desktop-client/src/components/common/Menu.tsx +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -6,7 +6,7 @@ import { useState, } from 'react'; -import { theme } from '../../style'; +import { type CSSProperties, theme } from '../../style'; import Text from './Text'; import View from './View'; @@ -31,6 +31,7 @@ type MenuItem = { iconSize?: number; text: string; key?: string; + style?: CSSProperties; }; type MenuProps<T extends MenuItem = MenuItem> = { @@ -38,6 +39,7 @@ type MenuProps<T extends MenuItem = MenuItem> = { footer?: ReactNode; items: Array<T | typeof Menu.line>; onMenuSelect: (itemName: T['name']) => void; + style?: CSSProperties; }; export default function Menu<T extends MenuItem>({ @@ -45,6 +47,7 @@ export default function Menu<T extends MenuItem>({ footer, items: allItems, onMenuSelect, + style, }: MenuProps<T>) { const elRef = useRef(null); const items = allItems.filter(x => x); @@ -101,7 +104,7 @@ export default function Menu<T extends MenuItem>({ return ( <View - style={{ outline: 'none', borderRadius: 4, overflow: 'hidden' }} + style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }} tabIndex={1} innerRef={elRef} > @@ -155,6 +158,7 @@ export default function Menu<T extends MenuItem>({ backgroundColor: theme.menuItemBackgroundHover, color: theme.menuItemTextHover, }), + ...item.style, }} onMouseEnter={() => setHoveredIndex(idx)} onMouseLeave={() => setHoveredIndex(null)} @@ -168,7 +172,10 @@ export default function Menu<T extends MenuItem>({ createElement(item.icon, { width: item.iconSize || 10, height: item.iconSize || 10, - style: { marginRight: 7, width: 10 }, + style: { + marginRight: 7, + width: item.iconSize || 10, + }, })} </Text> <Text>{item.text}</Text> diff --git a/packages/desktop-client/src/components/common/Modal.tsx b/packages/desktop-client/src/components/common/Modal.tsx index 6f1de3b2f0861803c0cddd5792c2709db88fde6d..4daa0c31f2962fbf2203bcc046c3f5dda7ab25e0 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 ( <ReactModal isOpen={true} onRequestClose={onClose} - shouldCloseOnOverlayClick={false} + shouldCloseOnOverlayClick={true} shouldFocusAfterRender={!global.IS_DESIGN_MODE} shouldReturnFocusAfterClose={focusAfterClose} appElement={document.querySelector('#root') as HTMLElement} parentSelector={parent && (() => 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, }} > + <View + style={{ + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + }} + > + <View + style={{ + flexDirection: 'row', + marginLeft: 15, + }} + > + {leftHeaderContent && !isEditingTitle + ? leftHeaderContent + : null} + </View> + </View> + {showTitle && ( <View style={{ @@ -155,17 +210,38 @@ const Modal = ({ width: 'calc(100% - 40px)', }} > - <Text - style={{ - fontSize: 25, - fontWeight: 700, - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }} - > - {title} - </Text> + {isEditingTitle ? ( + <Input + style={{ + fontSize: 25, + fontWeight: 700, + textAlign: 'center', + }} + value={_title} + onChange={e => setTitle(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + _onTitleUpdate(e.currentTarget.value); + } + }} + onBlur={e => _onTitleUpdate(e.target.value)} + /> + ) : ( + <Text + style={{ + fontSize: 25, + fontWeight: 700, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + ...titleStyle, + }} + {...(editableTitle && { onPointerUp: onTitleClick })} + > + {_title} + </Text> + )} </View> )} @@ -185,7 +261,7 @@ const Modal = ({ marginRight: 15, }} > - {showClose && ( + {showClose && !isEditingTitle && ( <Button type="bare" onClick={onClose} @@ -200,7 +276,9 @@ const Modal = ({ </View> )} <View style={{ padding, paddingTop: 0, flex: 1 }}> - {typeof children === 'function' ? children() : children} + {typeof children === 'function' + ? children({ isEditingTitle }) + : children} </View> {loading && ( <View diff --git a/packages/desktop-client/src/components/modals/CategoryGroupMenu.tsx b/packages/desktop-client/src/components/modals/CategoryGroupMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55eb435c1d3fade549061881139605a0f0dacba0 --- /dev/null +++ b/packages/desktop-client/src/components/modals/CategoryGroupMenu.tsx @@ -0,0 +1,261 @@ +import React, { type ComponentProps, useState } from 'react'; + +import { useLiveQuery } from 'loot-core/src/client/query-hooks'; +import q from 'loot-core/src/shared/query'; +import { type CategoryGroupEntity } from 'loot-core/src/types/models'; + +import useCategories from '../../hooks/useCategories'; +import { DotsHorizontalTriple } from '../../icons/v1'; +import Add from '../../icons/v1/Add'; +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 CategoryGroupMenuProps = { + modalProps: CommonModalProps; + groupId: string; + onSave: (group: CategoryGroupEntity) => 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 ( + <Modal + title={group.name} + showHeader + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 1, + height: '45vh', + padding: '0 10px', + borderRadius: '6px', + }} + editableTitle={true} + titleStyle={styles.underlinedText} + onTitleUpdate={onNameUpdate} + leftHeaderContent={ + <AdditionalCategoryGroupMenu + group={group} + onDelete={_onDelete} + onToggleVisibility={_onToggleVisibility} + /> + } + > + {({ isEditingTitle }) => ( + <View + style={{ + flex: 1, + flexDirection: 'column', + }} + > + <View + style={{ + overflowY: 'auto', + flex: 1, + }} + > + <Notes + notes={notes?.length > 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, + }), + })} + /> + </View> + <View + style={{ + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignContent: 'space-between', + paddingTop: 10, + paddingBottom: 10, + }} + > + <Button + disabled={isEditingTitle} + style={{ + ...buttonStyle, + display: isEditingTitle ? 'none' : undefined, + }} + onClick={_onAddCategory} + > + <Add width={17} height={17} style={{ paddingRight: 5 }} /> + Add category + </Button> + <Button + style={{ + ...buttonStyle, + display: isEditingTitle ? 'none' : undefined, + }} + onClick={_onEditNotes} + > + <NotesPaper width={20} height={20} style={{ paddingRight: 5 }} /> + Edit notes + </Button> + </View> + </View> + )} + </Modal> + ); +} + +function AdditionalCategoryGroupMenu({ group, onDelete, onToggleVisibility }) { + const [menuOpen, setMenuOpen] = useState(false); + const itemStyle: CSSProperties = { + ...styles.mediumText, + height: BUTTON_HEIGHT, + }; + + return ( + <View> + <Button + type="bare" + aria-label="Menu" + onClick={() => { + setMenuOpen(true); + }} + > + <DotsHorizontalTriple + width={17} + height={17} + style={{ color: 'currentColor' }} + /> + {menuOpen && ( + <Tooltip + position="bottom-left" + style={{ padding: 0 }} + onClose={() => { + setMenuOpen(false); + }} + > + <Menu + style={{ + ...styles.mediumText, + color: theme.formLabelText, + }} + items={ + [ + { + name: 'toggleVisibility', + text: group.hidden ? 'Show' : 'Hide', + icon: group.hidden ? ViewShow : ViewHide, + iconSize: 16, + style: itemStyle, + }, + ...(!group.is_income && [ + Menu.line, + { + name: 'delete', + text: 'Delete', + icon: Trash, + iconSize: 15, + style: itemStyle, + }, + ]), + ].filter(i => i != null) as ComponentProps<typeof Menu>['items'] + } + onMenuSelect={itemName => { + setMenuOpen(false); + if (itemName === 'delete') { + onDelete(); + } else if (itemName === 'toggleVisibility') { + onToggleVisibility(); + } + }} + /> + </Tooltip> + )} + </Button> + </View> + ); +} 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 0000000000000000000000000000000000000000..5ad9f6fc0199ff9ce6f5274d5af64ca0fd53fc4b --- /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 ( + <Modal + title={category.name} + titleStyle={styles.underlinedText} + showHeader + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 1, + height: '45vh', + padding: '0 10px', + borderRadius: '6px', + }} + editableTitle={true} + onTitleUpdate={onNameUpdate} + leftHeaderContent={ + <AdditionalCategoryMenu + category={category} + onDelete={_onDelete} + onToggleVisibility={_onToggleVisibility} + /> + } + > + {({ isEditingTitle }) => ( + <View + style={{ + flex: 1, + flexDirection: 'column', + }} + > + <View + style={{ + overflowY: 'auto', + flex: 1, + }} + > + <Notes + notes={originalNotes?.length > 0 ? originalNotes : 'No notes'} + editable={false} + focused={false} + getStyle={editable => ({ + borderRadius: 6, + ...((!originalNotes || originalNotes.length === 0) && { + justifySelf: 'center', + alignSelf: 'center', + color: theme.pageTextSubdued, + }), + })} + /> + </View> + <View + style={{ + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'space-between', + alignContent: 'space-between', + margin: '10px 0', + }} + > + <Button + style={{ + ...buttonStyle, + display: isEditingTitle ? 'none' : undefined, + }} + onClick={_onEditNotes} + > + <NotesPaper width={20} height={20} style={{ paddingRight: 5 }} /> + Edit notes + </Button> + </View> + </View> + )} + </Modal> + ); +} + +function AdditionalCategoryMenu({ category, onDelete, onToggleVisibility }) { + const [menuOpen, setMenuOpen] = useState(false); + const itemStyle: CSSProperties = { + ...styles.mediumText, + height: BUTTON_HEIGHT, + }; + + return ( + <View> + <Button + type="bare" + aria-label="Menu" + onClick={() => { + setMenuOpen(true); + }} + > + <DotsHorizontalTriple + width={17} + height={17} + style={{ color: 'currentColor' }} + /> + {menuOpen && ( + <Tooltip + position="bottom-left" + style={{ padding: 0 }} + onClose={() => { + setMenuOpen(false); + }} + > + <Menu + items={[ + { + name: 'toggleVisibility', + text: category.hidden ? 'Show' : 'Hide', + icon: category.hidden ? ViewShow : ViewHide, + iconSize: 16, + style: itemStyle, + }, + Menu.line, + { + name: 'delete', + text: 'Delete', + icon: Trash, + iconSize: 15, + style: itemStyle, + }, + ]} + onMenuSelect={itemName => { + setMenuOpen(false); + if (itemName === 'delete') { + onDelete(); + } else if (itemName === 'toggleVisibility') { + onToggleVisibility(); + } + }} + /> + </Tooltip> + )} + </Button> + </View> + ); +} diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index 9893fa71f433c9d1c78caae3b850a3d6192ec688..ee0707bad80b4ee48827b25b7a44feb5c2851bb2 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' }} > {() => ( <View diff --git a/packages/desktop-client/src/components/modals/ManageRulesModal.tsx b/packages/desktop-client/src/components/modals/ManageRulesModal.tsx index 59dccf84a5a0bfdae593c639cffa2cf87219b312..cea802135cb6c65d184be2c1f32dfe419a345a28 100644 --- a/packages/desktop-client/src/components/modals/ManageRulesModal.tsx +++ b/packages/desktop-client/src/components/modals/ManageRulesModal.tsx @@ -34,8 +34,6 @@ export default function ManageRulesModal({ {...modalProps} style={{ flex: 1, - maxWidth: '90%', - maxHeight: '90%', }} > {() => <ManageRules isModal payeeId={payeeId} setLoading={setLoading} />} 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 0000000000000000000000000000000000000000..3ae4cd95cd411288f1fa828c6067aa50b19be30e --- /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 ( + <Modal + title={`Notes: ${name}`} + showHeader + focusAfterClose={false} + {...modalProps} + onClose={_onClose} + padding={0} + style={{ + flex: 1, + height: '50vh', + padding: '0 10px', + borderRadius: '6px', + }} + > + {() => ( + <View + style={{ + flex: 1, + flexDirection: 'column', + }} + > + <NotesComponent + notes={notes} + editable={true} + focused={true} + getStyle={editable => ({ + borderRadius: 6, + flex: 1, + minWidth: 0, + })} + onChange={setNotes} + /> + <View + style={{ + flexDirection: 'column', + alignItems: 'center', + justifyItems: 'center', + width: '100%', + paddingTop: 10, + paddingBottom: 10, + }} + > + <Button + type="primary" + style={{ + fontSize: 17, + fontWeight: 400, + width: '100%', + }} + onClick={_onSave} + > + <Check width={17} height={17} style={{ paddingRight: 5 }} /> + Save notes + </Button> + </View> + </View> + )} + </Modal> + ); +} diff --git a/packages/desktop-client/src/components/modals/SingleInput.tsx b/packages/desktop-client/src/components/modals/SingleInput.tsx index 675a44132609471819a67704d91f5e632bfcfca3..db2ed81a088aeea98b7b7679f01e0dc5fba02fca 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({ <InitialFocus> <Input placeholder={inputPlaceholder} + style={{ ...styles.mediumText }} onUpdate={setValue} onEnter={e => _onSubmit(e.currentTarget.value)} /> @@ -68,11 +70,20 @@ function SingleInput({ <View style={{ flexDirection: 'row', + alignContent: 'center', justifyContent: 'center', - paddingBottom: 15, }} > - <Button onPointerUp={e => _onSubmit(value)}>{buttonText}</Button> + <Button + type="primary" + style={{ + ...styles.mediumText, + flexBasis: '50%', + }} + onPointerUp={e => _onSubmit(value)} + > + {buttonText} + </Button> </View> </> )} diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.jsx b/packages/desktop-client/src/components/reports/reports/CustomReport.jsx index 4a5f019bcc85382e3cbc7f788e1a3dea23e72930..307b630033fd12f8f4c04c72a43f03cbdd387030 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} /> ) : ( - <LoadingIndicator message={'Loading report...'} /> + <LoadingIndicator message="Loading report..." /> )} </View> {(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 77f5eccdafcc8c324dd9faa456e49d84f2a29b64..ba99695416da4f561cd57907c7bb7a1c2cc17190 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/util/GenericInput.jsx b/packages/desktop-client/src/components/util/GenericInput.jsx index e62807b60e2728d1584eea60d0d8648cfd2ebe71..dfa2d95ddcb5a3eeef16560f298142c9b3cffabd 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({ <Input inputRef={inputRef} defaultValue={value || ''} - placeholder={'yyyy'} + placeholder="yyyy" onEnter={e => onChange(e.target.value)} onBlur={e => onChange(e.target.value)} /> diff --git a/packages/loot-core/src/client/actions/modals.ts b/packages/loot-core/src/client/actions/modals.ts index 7abf5e78a0eda269080c9753db37305e5d7c4245..879092f454e8bba369126d55aa2eb07bcfd02a7d 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 95a7e8fa9dc9814e38057813d8e60f070ff12d23..c107dea1c823b106d38e4b92193624e126c7e389 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 eeb743869d6fc2b2782db03f02315424440bc259..57d32784bce12ac3e474de3b456a163375cb6b35 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 a5b0396c404d4f81fa16f1b5d69448989431571f..36771e2e9844c606ffe41681fe9f0ee54466c289 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'; @@ -105,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 = { @@ -125,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/upcoming-release-notes/1964.md b/upcoming-release-notes/1964.md new file mode 100644 index 0000000000000000000000000000000000000000..16d45249fb9a9d64a6f08d249495f5b8e87f8817 --- /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.