{items.map((item, index) => {
@@ -134,15 +163,15 @@ function defaultRenderItems(items, getItemProps, highlightedIndex) {
);
}
-function defaultShouldSaveFromKey(e) {
+function defaultShouldSaveFromKey(e: KeyboardEvent) {
return e.code === 'Enter';
}
-function defaultItemToString(item) {
+function defaultItemToString
(item?: T) {
return item ? getItemName(item) : '';
}
-type SingleAutocompleteProps = {
+type SingleAutocompleteProps = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps;
@@ -150,31 +179,31 @@ type SingleAutocompleteProps = {
inputProps?: Omit, 'onChange'> & {
onChange?: (value: string) => void;
};
- suggestions?: unknown[];
+ suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps;
renderInput?: (props: ComponentProps) => ReactNode;
renderItems?: (
- items,
- getItemProps: (arg: { item: unknown }) => ComponentProps,
+ items: T[],
+ getItemProps: (arg: { item: T }) => ComponentProps,
idx: number,
- value?: unknown,
+ value?: string,
) => ReactNode;
- itemToString?: (item) => string;
+ itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
- filterSuggestions?: (suggestions, value: string) => unknown[];
+ filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
- getHighlightedIndex?: (suggestions) => number | null;
+ getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
- onUpdate?: (id: unknown, value: string) => void;
+ onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
- onSelect: (id: unknown, value: string) => void;
+ onSelect: (id: T['id'], value: string) => void;
tableBehavior?: boolean;
closeOnBlur?: boolean;
- value: unknown[] | string;
+ value: T | T['id'];
isMulti?: boolean;
};
-function SingleAutocomplete({
+function SingleAutocomplete({
focused,
embedded = false,
containerProps,
@@ -198,7 +227,7 @@ function SingleAutocomplete({
closeOnBlur = true,
value: initialValue,
isMulti = false,
-}: SingleAutocompleteProps) {
+}: SingleAutocompleteProps) {
const [selectedItem, setSelectedItem] = useState(() =>
findItem(strict, suggestions, initialValue),
);
@@ -220,9 +249,9 @@ function SingleAutocomplete({
setSelectedItem(findItem(strict, suggestions, initialValue));
}, [initialValue, suggestions, strict]);
- function resetState(newValue) {
+ function resetState(newValue?: string) {
const val = newValue === undefined ? initialValue : newValue;
- const selectedItem = findItem(strict, suggestions, val);
+ const selectedItem = findItem(strict, suggestions, val);
setSelectedItem(selectedItem);
setValue(selectedItem ? getItemName(selectedItem) : '');
@@ -527,7 +556,12 @@ function SingleAutocomplete({
);
}
-function MultiItem({ name, onRemove }) {
+type MultiItemProps = {
+ name: string;
+ onRemove: () => void;
+};
+
+function MultiItem({ name, onRemove }: MultiItemProps) {
return (
& {
- value: unknown[];
- onSelect: (ids: unknown[], id?: string) => void;
+type MultiAutocompleteProps<
+ T extends Item,
+ Value = SingleAutocompleteProps['value'],
+> = Omit, 'value' | 'onSelect'> & {
+ value: Value[];
+ onSelect: (ids: Value[], id?: string) => void;
};
-function MultiAutocomplete({
+function MultiAutocomplete({
value: selectedItems,
onSelect,
suggestions,
strict,
...props
-}: MultiAutocompleteProps) {
+}: MultiAutocompleteProps) {
const [focused, setFocused] = useState(false);
- const lastSelectedItems = useRef();
+ const lastSelectedItems = useRef();
useEffect(() => {
lastSelectedItems.current = selectedItems;
});
- function onRemoveItem(id) {
+ function onRemoveItem(id: (typeof selectedItems)[0]) {
const items = selectedItems.filter(i => i !== id);
onSelect(items);
}
- function onAddItem(id) {
+ function onAddItem(id: string) {
if (id) {
id = id.trim();
onSelect([...selectedItems, id], id);
}
}
- function onKeyDown(e, prevOnKeyDown) {
- if (e.key === 'Backspace' && e.target.value === '') {
+ function onKeyDown(
+ e: KeyboardEvent,
+ prevOnKeyDown?: ComponentProps['onKeyDown'],
+ ) {
+ if (e.key === 'Backspace' && e.currentTarget.value === '') {
onRemoveItem(selectedItems[selectedItems.length - 1]);
}
@@ -680,31 +717,24 @@ export function AutocompleteFooter({
);
}
-type AutocompleteProps =
- | ComponentProps
- | ComponentProps;
+type AutocompleteProps =
+ | ComponentProps>
+ | ComponentProps>;
-function isMultiAutocomplete(
- props: AutocompleteProps,
+function isMultiAutocomplete(
+ _props: AutocompleteProps,
multi?: boolean,
-): props is ComponentProps {
+): _props is ComponentProps> {
return multi;
}
-function isSingleAutocomplete(
- props: AutocompleteProps,
- multi?: boolean,
-): props is ComponentProps {
- return !multi;
-}
-
-export default function Autocomplete({
+export default function Autocomplete({
multi,
...props
-}: AutocompleteProps & { multi?: boolean }) {
+}: AutocompleteProps & { multi?: boolean }) {
if (isMultiAutocomplete(props, multi)) {
return ;
- } else if (isSingleAutocomplete(props, multi)) {
- return ;
}
+
+ return ;
}
diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx
index 6ed812c7b02..1c908d0be68 100644
--- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx
+++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx
@@ -308,6 +308,7 @@ export default function PayeeAutocomplete({
const isf = filtered.length > 100;
filtered = filtered.slice(0, 100);
+ // @ts-expect-error TODO: solve this somehow
filtered.filtered = isf;
if (filtered.length >= 2 && filtered[0].id === 'new') {
diff --git a/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx
index a2800b85646..989e0b17123 100644
--- a/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx
+++ b/packages/desktop-client/src/components/autocomplete/SavedFilterAutocomplete.tsx
@@ -1,25 +1,26 @@
import React, { type ComponentProps } from 'react';
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
+import { type TransactionFilterEntity } from 'loot-core/src/types/models';
import { theme } from '../../style';
import View from '../common/View';
import Autocomplete from './Autocomplete';
-type FilterListProps = {
- items: { id: string; name: string }[];
- getItemProps: (arg: { item: unknown }) => ComponentProps;
+type FilterListProps = {
+ items: T[];
+ getItemProps: (arg: { item: T }) => ComponentProps;
highlightedIndex: number;
embedded?: boolean;
};
-function FilterList({
+function FilterList({
items,
getItemProps,
highlightedIndex,
embedded,
-}: FilterListProps) {
+}: FilterListProps) {
return (
;
+} & ComponentProps>;
export default function SavedFilterAutocomplete({
embedded,
@@ -73,6 +74,7 @@ export default function SavedFilterAutocomplete({
suggestions={filters}
renderItems={(items, getItemProps, highlightedIndex) => (
{
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',
}}
>
-
@@ -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({
+
+ );
+});
-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/util/AmountInput.tsx b/packages/desktop-client/src/components/util/AmountInput.tsx
index 632082cf9ea..9adec5f3adb 100644
--- a/packages/desktop-client/src/components/util/AmountInput.tsx
+++ b/packages/desktop-client/src/components/util/AmountInput.tsx
@@ -1,4 +1,10 @@
-import React, { type Ref, useRef, useState, useEffect } from 'react';
+import React, {
+ type Ref,
+ useRef,
+ useState,
+ useEffect,
+ type FocusEventHandler,
+} from 'react';
import evalArithmetic from 'loot-core/src/shared/arithmetic';
import { amountToInteger } from 'loot-core/src/shared/util';
@@ -15,69 +21,75 @@ import useFormat from '../spreadsheet/useFormat';
type AmountInputProps = {
id?: string;
inputRef?: Ref;
- 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/package.json b/packages/loot-core/package.json
index 44c8460f753..d6c155b264f 100644
--- a/packages/loot-core/package.json
+++ b/packages/loot-core/package.json
@@ -46,7 +46,7 @@
"@swc/core": "^1.3.82",
"@swc/helpers": "^0.5.1",
"@swc/jest": "^0.2.29",
- "@types/better-sqlite3": "^7.6.7",
+ "@types/better-sqlite3": "^7.6.8",
"@types/jest": "^27.5.0",
"@types/jlongster__sql.js": "npm:@types/sql.js@latest",
"@types/pegjs": "^0.10.3",
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/data-hooks/filters.ts b/packages/loot-core/src/client/data-hooks/filters.ts
index 60f9999e81a..f9b4a1188a3 100644
--- a/packages/loot-core/src/client/data-hooks/filters.ts
+++ b/packages/loot-core/src/client/data-hooks/filters.ts
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
+import { type TransactionFilterEntity } from '../../types/models';
import q from '../query-helpers';
import { useLiveQuery } from '../query-hooks';
@@ -17,7 +18,7 @@ function toJS(rows) {
return filters;
}
-export function useFilters() {
+export function useFilters(): TransactionFilterEntity[] {
const filters = toJS(
useLiveQuery(() => q('transaction_filters').select('*'), []) || [],
);
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/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts
index fe8c077055a..862648e4d9b 100644
--- a/packages/loot-core/src/server/accounts/sync.ts
+++ b/packages/loot-core/src/server/accounts/sync.ts
@@ -436,7 +436,7 @@ export async function reconcileGoCardlessTransactions(acctId, transactions) {
// matched transaction. See the final pass below for the needed
// fields.
fuzzyDataset = await db.all(
- `SELECT id, is_parent, date, imported_id, payee, category, notes FROM v_transactions
+ `SELECT id, is_parent, date, imported_id, payee, category, notes, reconciled FROM v_transactions
WHERE date >= ? AND date <= ? AND amount = ? AND account = ? AND is_child = 0`,
[
db.toDateRepr(monthUtils.subDays(trans.date, 4)),
@@ -494,6 +494,11 @@ export async function reconcileGoCardlessTransactions(acctId, transactions) {
// Finally, generate & commit the changes
for (const { trans, subtransactions, match } of transactionsStep3) {
if (match) {
+ // Skip updating already reconciled (locked) transactions
+ if (match.reconciled) {
+ continue;
+ }
+
// TODO: change the above sql query to use aql
const existing = {
...match,
@@ -594,7 +599,7 @@ export async function reconcileTransactions(acctId, transactions) {
// matched transaction. See the final pass below for the needed
// fields.
fuzzyDataset = await db.all(
- `SELECT id, is_parent, date, imported_id, payee, category, notes FROM v_transactions
+ `SELECT id, is_parent, date, imported_id, payee, category, notes, reconciled FROM v_transactions
WHERE date >= ? AND date <= ? AND amount = ? AND account = ? AND is_child = 0`,
[
db.toDateRepr(monthUtils.subDays(trans.date, 4)),
@@ -652,6 +657,11 @@ export async function reconcileTransactions(acctId, transactions) {
// Finally, generate & commit the changes
for (const { trans, subtransactions, match } of transactionsStep3) {
if (match) {
+ // Skip updating already reconciled (locked) transactions
+ if (match.reconciled) {
+ continue;
+ }
+
// TODO: change the above sql query to use aql
const existing = {
...match,
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/budget/goaltemplates.ts b/packages/loot-core/src/server/budget/goaltemplates.ts
index 6c71ee2a191..256053eda4a 100644
--- a/packages/loot-core/src/server/budget/goaltemplates.ts
+++ b/packages/loot-core/src/server/budget/goaltemplates.ts
@@ -590,6 +590,7 @@ async function applyCategoryTemplate(
last_month_balance,
to_budget,
errors,
+ category,
);
to_budget = goalsReturn.to_budget;
errors = goalsReturn.errors;
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 9532405d486..346eb3f16ce 100644
--- a/packages/loot-core/src/server/sync/sync.property.test.ts
+++ b/packages/loot-core/src/server/sync/sync.property.test.ts
@@ -93,7 +93,7 @@ const baseTime = 1565374471903;
const clientId1 = '80dd7da215247293';
const clientId2 = '90xU1sd5124329ac';
-function makeGen({
+function makeGen>({
table,
row,
field,
@@ -102,7 +102,7 @@ function makeGen({
table: string;
row?: Arbitrary;
field: string;
- value: Arbitrary;
+ value: T;
}) {
return jsc.record({
dataset: jsc.constant(table),
@@ -127,7 +127,7 @@ function makeGen({
});
}
-const generators = [];
+const generators: Array> = [];
Object.keys(schema).forEach(table => {
Object.keys(schema[table]).reduce((obj, field) => {
if (table === 'spreadsheet_cells' && field === 'expr') {
diff --git a/packages/loot-core/src/types/models/index.d.ts b/packages/loot-core/src/types/models/index.d.ts
index d411ff64eb8..cceded549bb 100644
--- a/packages/loot-core/src/types/models/index.d.ts
+++ b/packages/loot-core/src/types/models/index.d.ts
@@ -6,3 +6,4 @@ export type * from './payee';
export type * from './rule';
export type * from './schedule';
export type * from './transaction';
+export type * from './transaction-filter';
diff --git a/packages/loot-core/src/types/models/transaction-filter.d.ts b/packages/loot-core/src/types/models/transaction-filter.d.ts
new file mode 100644
index 00000000000..e43d50e21e3
--- /dev/null
+++ b/packages/loot-core/src/types/models/transaction-filter.d.ts
@@ -0,0 +1,7 @@
+export interface TransactionFilterEntity {
+ id: string;
+ name: string;
+ conditions_op: string;
+ conditions: unknown;
+ tombstone: boolean;
+}
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/2066.md b/upcoming-release-notes/2066.md
new file mode 100644
index 00000000000..6ff11102d47
--- /dev/null
+++ b/upcoming-release-notes/2066.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+Fixing TypeScript issues when enabling `strictFunctionTypes` (pt.2).
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/2070.md b/upcoming-release-notes/2070.md
new file mode 100644
index 00000000000..f04d8bd7ac7
--- /dev/null
+++ b/upcoming-release-notes/2070.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+Fixing TypeScript issues when enabling `strictFunctionTypes` (pt.3).
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/2096.md b/upcoming-release-notes/2096.md
new file mode 100644
index 00000000000..ee37321c4a8
--- /dev/null
+++ b/upcoming-release-notes/2096.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [MatissJanis]
+---
+
+Fix category spending report (experimental) not loading [#1981](https://github.com/actualbudget/actual/issues/1981)
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/2111.md b/upcoming-release-notes/2111.md
new file mode 100644
index 00000000000..3da51c67cb9
--- /dev/null
+++ b/upcoming-release-notes/2111.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+eslint: disallow unnecessary curly braces
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/2125.md b/upcoming-release-notes/2125.md
new file mode 100644
index 00000000000..0197a4b53a3
--- /dev/null
+++ b/upcoming-release-notes/2125.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [mk-french]
+---
+
+Goals: Negate schedule amount to budget if income
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
diff --git a/upcoming-release-notes/2140.md b/upcoming-release-notes/2140.md
new file mode 100644
index 00000000000..91b0355db3e
--- /dev/null
+++ b/upcoming-release-notes/2140.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [MatissJanis]
+---
+
+Fix imported transactions overriding reconciled (locked) transaction data
diff --git a/yarn.lock b/yarn.lock
index 03925d9128b..03fe210bc28 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4299,12 +4299,12 @@ __metadata:
languageName: node
linkType: hard
-"@types/better-sqlite3@npm:^7.6.7":
- version: 7.6.7
- resolution: "@types/better-sqlite3@npm:7.6.7"
+"@types/better-sqlite3@npm:^7.6.8":
+ version: 7.6.8
+ resolution: "@types/better-sqlite3@npm:7.6.8"
dependencies:
"@types/node": "npm:*"
- checksum: 5021c1bae4a494408c1a77d84bc31dc15e373b8a1cf8880acba6517f63bc5c2dbf032c81938641346fb967600dbebc1475033c2458b5a5b93eb8f2c53bdbcbf8
+ checksum: 404e9b7210564866b0f8878353cc6a16c6ffb313077cbb5aec6176ad2b0a30f64236f03f0a40d36d86bf4eab7658bdcd6d6a8a65dc377de7910fc9e9932885a4
languageName: node
linkType: hard
@@ -13356,7 +13356,7 @@ __metadata:
"@swc/helpers": "npm:^0.5.1"
"@swc/jest": "npm:^0.2.29"
"@types/adm-zip": "npm:^0.5.0"
- "@types/better-sqlite3": "npm:^7.6.7"
+ "@types/better-sqlite3": "npm:^7.6.8"
"@types/jest": "npm:^27.5.0"
"@types/jlongster__sql.js": "npm:@types/sql.js@latest"
"@types/pegjs": "npm:^0.10.3"