[Maintenance] Update types for budget table components (#6022)

* [Maintenance] Update types for budget table components + new hooks to reduce prop drilling

* Require BudgetCategories props
This commit is contained in:
Joel Jeremy Marquez
2025-11-18 09:45:53 -08:00
committed by GitHub
parent f3419a4ee2
commit 97dec0d3c8
16 changed files with 437 additions and 439 deletions

View File

@@ -1,9 +1,4 @@
import React, {
memo,
useState,
useMemo,
type ComponentPropsWithoutRef,
} from 'react';
import React, { memo, useState, useMemo } from 'react';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -52,32 +47,15 @@ type LocalDragState =
type BudgetCategoriesProps = {
categoryGroups: CategoryGroupEntity[];
editingCell: { id: string; cell: string } | null;
dataComponents: {
ExpenseGroupComponent: ComponentPropsWithoutRef<
typeof ExpenseGroup
>['MonthComponent'];
ExpenseCategoryComponent: ComponentPropsWithoutRef<
typeof ExpenseCategory
>['MonthComponent'];
IncomeHeaderComponent: ComponentPropsWithoutRef<
typeof IncomeHeader
>['MonthComponent'];
IncomeGroupComponent: ComponentPropsWithoutRef<
typeof IncomeGroup
>['MonthComponent'];
IncomeCategoryComponent: ComponentPropsWithoutRef<
typeof IncomeCategory
>['MonthComponent'];
};
onBudgetAction: (month: string, action: string, arg: unknown) => void;
onShowActivity: (id: CategoryEntity['id'], month?: string) => void;
onEditName?: (id: CategoryEntity['id']) => void;
onEditMonth?: (id: CategoryEntity['id'], month: string) => void;
onSaveCategory?: (category: CategoryEntity) => void;
onSaveGroup?: (group: CategoryGroupEntity) => void;
onEditName: (id: CategoryEntity['id']) => void;
onEditMonth: (id: CategoryEntity['id'], month: string) => void;
onSaveCategory: (category: CategoryEntity) => void;
onSaveGroup: (group: CategoryGroupEntity) => void;
onDeleteCategory: (id: CategoryEntity['id']) => void;
onDeleteGroup?: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup?: (categoryIds: CategoryEntity['id'][]) => void;
onDeleteGroup: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup: (categoryIds: CategoryEntity['id'][]) => void;
onReorderCategory: OnDropCallback;
onReorderGroup: OnDropCallback;
};
@@ -86,7 +64,6 @@ export const BudgetCategories = memo<BudgetCategoriesProps>(
({
categoryGroups,
editingCell,
dataComponents,
onBudgetAction,
onShowActivity,
onEditName,
@@ -319,7 +296,6 @@ export const BudgetCategories = memo<BudgetCategoriesProps>(
group={item.value}
editingCell={editingCell}
collapsed={collapsedGroupIds.includes(item.value.id)}
MonthComponent={dataComponents.ExpenseGroupComponent}
dragState={dragState}
onEditName={onEditName}
onSave={_onSaveGroup}
@@ -339,7 +315,6 @@ export const BudgetCategories = memo<BudgetCategoriesProps>(
cat={item.value}
categoryGroup={item.group}
editingCell={editingCell}
MonthComponent={dataComponents.ExpenseCategoryComponent}
dragState={dragState}
onEditName={onEditName}
onEditMonth={onEditMonth}
@@ -360,10 +335,7 @@ export const BudgetCategories = memo<BudgetCategoriesProps>(
backgroundColor: theme.tableBackground,
}}
>
<IncomeHeader
MonthComponent={dataComponents.IncomeHeaderComponent}
onShowNewGroup={onShowNewGroup}
/>
<IncomeHeader onShowNewGroup={onShowNewGroup} />
</View>
);
break;
@@ -372,7 +344,6 @@ export const BudgetCategories = memo<BudgetCategoriesProps>(
<IncomeGroup
group={item.value}
editingCell={editingCell}
MonthComponent={dataComponents.IncomeGroupComponent}
collapsed={collapsedGroupIds.includes(item.value.id)}
onEditName={onEditName!}
onSave={_onSaveGroup}
@@ -387,8 +358,7 @@ export const BudgetCategories = memo<BudgetCategoriesProps>(
cat={item.value}
editingCell={editingCell}
isLast={idx === items.length - 1}
MonthComponent={dataComponents.IncomeCategoryComponent}
onEditName={onEditName!}
onEditName={onEditName}
onEditMonth={onEditMonth}
onSave={_onSaveCategory}
onDelete={onDeleteCategory}

View File

@@ -12,17 +12,13 @@ import { css } from '@emotion/css';
import { addMonths, subMonths } from 'loot-core/shared/months';
import { type BudgetSummary as EnvelopeBudgetSummary } from './envelope/budgetsummary/BudgetSummary';
import { MonthsContext } from './MonthsContext';
import { type BudgetSummary as TrackingBudgetSummary } from './tracking/budgetsummary/BudgetSummary';
import { useBudgetComponents } from '.';
import { useResizeObserver } from '@desktop-client/hooks/useResizeObserver';
type BudgetSummariesProps = {
SummaryComponent: typeof TrackingBudgetSummary | typeof EnvelopeBudgetSummary;
};
export function BudgetSummaries({ SummaryComponent }: BudgetSummariesProps) {
export function BudgetSummaries() {
const { months } = useContext(MonthsContext);
const [widthState, setWidthState] = useState(0);
@@ -65,6 +61,8 @@ export function BudgetSummaries({ SummaryComponent }: BudgetSummariesProps) {
spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
}, [monthWidth]);
const { SummaryComponent } = useBudgetComponents();
return (
<div
className={css([

View File

@@ -1,10 +1,4 @@
import React, {
type ComponentPropsWithoutRef,
type ComponentProps,
type KeyboardEvent,
useMemo,
useState,
} from 'react';
import React, { type KeyboardEvent, useMemo, useState } from 'react';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -39,19 +33,13 @@ type BudgetTableProps = {
startMonth: string;
numMonths: number;
monthBounds: MonthBounds;
dataComponents: {
SummaryComponent: ComponentPropsWithoutRef<
typeof BudgetSummaries
>['SummaryComponent'];
BudgetTotalsComponent: ComponentPropsWithoutRef<
typeof BudgetTotals
>['MonthComponent'];
} & ComponentProps<typeof BudgetCategories>['dataComponents'];
onSaveCategory: (category: CategoryEntity) => void;
onDeleteCategory: (id: CategoryEntity['id']) => void;
onSaveGroup: (group: CategoryGroupEntity) => void;
onDeleteGroup: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup: (categoryIds: CategoryEntity['id'][]) => void;
onApplyBudgetTemplatesInGroup: (
categoryIds: Array<CategoryEntity['id']>,
) => void;
onReorderCategory: (params: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
@@ -72,7 +60,6 @@ export function BudgetTable(props: BudgetTableProps) {
startMonth,
numMonths,
monthBounds,
dataComponents,
onSaveCategory,
onDeleteCategory,
onSaveGroup,
@@ -265,7 +252,7 @@ export function BudgetTable(props: BudgetTableProps) {
monthBounds={monthBounds}
type={type}
>
<BudgetSummaries SummaryComponent={dataComponents.SummaryComponent} />
<BudgetSummaries />
</MonthsProvider>
</View>
@@ -276,7 +263,6 @@ export function BudgetTable(props: BudgetTableProps) {
type={type}
>
<BudgetTotals
MonthComponent={dataComponents.BudgetTotalsComponent}
toggleHiddenCategories={toggleHiddenCategories}
expandAllCategories={expandAllCategories}
collapseAllCategories={collapseAllCategories}
@@ -300,7 +286,6 @@ export function BudgetTable(props: BudgetTableProps) {
<BudgetCategories
categoryGroups={categoryGroups}
editingCell={editing}
dataComponents={dataComponents}
onEditMonth={onEditMonth}
onEditName={onEditName}
onSaveCategory={onSaveCategory}

View File

@@ -1,4 +1,4 @@
import React, { type ComponentProps, memo, useRef, useState } from 'react';
import React, { memo, useRef, useState } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -17,17 +17,17 @@ import { View } from '@actual-app/components/view';
import { RenderMonths } from './RenderMonths';
import { getScrollbarWidth } from './util';
import { useBudgetComponents } from '.';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
type BudgetTotalsProps = {
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
toggleHiddenCategories: () => void;
expandAllCategories: () => void;
collapseAllCategories: () => void;
};
export const BudgetTotals = memo(function BudgetTotals({
MonthComponent,
toggleHiddenCategories,
expandAllCategories,
collapseAllCategories,
@@ -57,6 +57,8 @@ export const BudgetTotals = memo(function BudgetTotals({
}
};
const { BudgetTotalsComponent: MonthComponent } = useBudgetComponents();
return (
<View
data-testid="budget-totals"
@@ -177,7 +179,9 @@ export const BudgetTotals = memo(function BudgetTotals({
/>
</Popover>
</View>
<RenderMonths component={MonthComponent} />
<RenderMonths>
<MonthComponent />
</RenderMonths>
</View>
);
});

View File

@@ -31,12 +31,12 @@ function getNumPossibleMonths(width: number, categoryWidth: number) {
return 6;
}
type DynamicBudgetTableInnerProps = {
type DynamicBudgetTableProps = {
width: number;
height: number;
} & DynamicBudgetTableProps;
} & AutoSizingBudgetTableProps;
const DynamicBudgetTableInner = ({
const DynamicBudgetTable = ({
type,
width,
height,
@@ -46,7 +46,7 @@ const DynamicBudgetTableInner = ({
monthBounds,
onMonthSelect,
...props
}: DynamicBudgetTableInnerProps) => {
}: DynamicBudgetTableProps) => {
const { setDisplayMax } = useBudgetMonthCount();
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
const categoryExpandedState = categoryExpandedStatePref ?? 0;
@@ -150,9 +150,9 @@ const DynamicBudgetTableInner = ({
);
};
DynamicBudgetTableInner.displayName = 'DynamicBudgetTableInner';
DynamicBudgetTable.displayName = 'DynamicBudgetTable';
type DynamicBudgetTableProps = Omit<
type AutoSizingBudgetTableProps = Omit<
ComponentProps<typeof BudgetTable>,
'numMonths'
> & {
@@ -160,14 +160,14 @@ type DynamicBudgetTableProps = Omit<
onMonthSelect: (month: string, numMonths: number) => void;
};
export const DynamicBudgetTable = (props: DynamicBudgetTableProps) => {
export const AutoSizingBudgetTable = (props: AutoSizingBudgetTableProps) => {
return (
<AutoSizer>
{({ width, height }) => (
<DynamicBudgetTableInner width={width} height={height} {...props} />
<DynamicBudgetTable width={width} height={height} {...props} />
)}
</AutoSizer>
);
};
DynamicBudgetTable.displayName = 'DynamicBudgetTable';
AutoSizingBudgetTable.displayName = 'AutoSizingBudgetTable';

View File

@@ -12,6 +12,8 @@ import {
import { RenderMonths } from './RenderMonths';
import { SidebarCategory } from './SidebarCategory';
import { useBudgetComponents } from '.';
import {
useDraggable,
useDroppable,
@@ -28,7 +30,6 @@ type ExpenseCategoryProps = {
categoryGroup?: CategoryGroupEntity;
editingCell: { id: string; cell: string } | null;
dragState: DragState<CategoryEntity> | DragState<CategoryGroupEntity> | null;
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
onEditName?: ComponentProps<typeof SidebarCategory>['onEditName'];
onEditMonth?: (id: CategoryEntity['id'], month: string) => void;
onSave?: ComponentProps<typeof SidebarCategory>['onSave'];
@@ -44,7 +45,6 @@ export function ExpenseCategory({
categoryGroup,
editingCell,
dragState,
MonthComponent,
onEditName,
onEditMonth,
onSave,
@@ -74,6 +74,8 @@ export function ExpenseCategory({
onDrop: onReorder,
});
const { ExpenseCategoryComponent: MonthComponent } = useBudgetComponents();
return (
<Row
innerRef={dropRef}
@@ -102,18 +104,22 @@ export function ExpenseCategory({
onDelete={onDelete}
/>
<RenderMonths
component={MonthComponent}
editingMonth={
editingCell && editingCell.id === cat.id && editingCell.cell
}
args={{
category: cat,
onEdit: onEditMonth,
onBudgetAction,
onShowActivity,
}}
/>
<RenderMonths>
{({ month }) => (
<MonthComponent
month={month}
editing={
editingCell &&
editingCell.id === cat.id &&
editingCell.cell === month
}
category={cat}
onEdit={onEditMonth}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
/>
)}
</RenderMonths>
</View>
</Row>
);

View File

@@ -12,6 +12,8 @@ import {
import { RenderMonths } from './RenderMonths';
import { SidebarGroup } from './SidebarGroup';
import { useBudgetComponents } from '.';
import {
useDraggable,
useDroppable,
@@ -28,7 +30,6 @@ type ExpenseGroupProps = {
collapsed: boolean;
editingCell: { id: string; cell: string } | null;
dragState: DragState<CategoryEntity> | DragState<CategoryGroupEntity> | null;
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
@@ -49,7 +50,6 @@ export function ExpenseGroup({
collapsed,
editingCell,
dragState,
MonthComponent,
onEditName,
onSave,
onDelete,
@@ -87,6 +87,8 @@ export function ExpenseGroup({
},
});
const { ExpenseGroupComponent: MonthComponent } = useBudgetComponents();
return (
<Row
collapsed={true}
@@ -140,7 +142,9 @@ export function ExpenseGroup({
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
onShowNewCategory={onShowNewCategory}
/>
<RenderMonths component={MonthComponent} args={{ group }} />
<RenderMonths>
{({ month }) => <MonthComponent month={month} group={group} />}
</RenderMonths>
</View>
</Row>
);

View File

@@ -6,6 +6,8 @@ import { type CategoryEntity } from 'loot-core/types/models';
import { RenderMonths } from './RenderMonths';
import { SidebarCategory } from './SidebarCategory';
import { useBudgetComponents } from '.';
import {
useDraggable,
useDroppable,
@@ -20,7 +22,6 @@ type IncomeCategoryProps = {
cat: CategoryEntity;
isLast?: boolean;
editingCell: { id: CategoryEntity['id']; cell: string } | null;
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
onEditName: ComponentProps<typeof SidebarCategory>['onEditName'];
onEditMonth?: (id: CategoryEntity['id'], month: string) => void;
onSave: ComponentProps<typeof SidebarCategory>['onSave'];
@@ -35,7 +36,6 @@ export function IncomeCategory({
cat,
isLast,
editingCell,
MonthComponent,
onEditName,
onEditMonth,
onSave,
@@ -59,6 +59,8 @@ export function IncomeCategory({
onDrop: onReorder,
});
const { IncomeCategoryComponent: MonthComponent } = useBudgetComponents();
return (
<Row
innerRef={dropRef}
@@ -82,19 +84,23 @@ export function IncomeCategory({
onSave={onSave}
onDelete={onDelete}
/>
<RenderMonths
component={MonthComponent}
editingMonth={
editingCell && editingCell.id === cat.id && editingCell.cell
}
args={{
category: cat,
onEdit: onEditMonth,
isLast,
onShowActivity,
onBudgetAction,
}}
/>
<RenderMonths>
{({ month }) => (
<MonthComponent
month={month}
editing={
editingCell &&
editingCell.id === cat.id &&
editingCell.cell === month
}
category={cat}
isLast={isLast}
onEdit={onEditMonth}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
/>
)}
</RenderMonths>
</Row>
);
}

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { type JSX } from 'react';
import React from 'react';
import { theme } from '@actual-app/components/theme';
@@ -8,13 +8,14 @@ import { type CategoryGroupEntity } from 'loot-core/types/models';
import { RenderMonths } from './RenderMonths';
import { SidebarGroup } from './SidebarGroup';
import { useBudgetComponents } from '.';
import { Row } from '@desktop-client/components/table';
type IncomeGroupProps = {
group: CategoryGroupEntity;
editingCell: { id: CategoryGroupEntity['id']; cell: string } | null;
collapsed: boolean;
MonthComponent: () => JSX.Element;
onEditName: (id: CategoryGroupEntity['id']) => void;
onSave: (group: CategoryGroupEntity) => void;
onToggleCollapse: (id: CategoryGroupEntity['id']) => void;
@@ -25,12 +26,12 @@ export function IncomeGroup({
group,
editingCell,
collapsed,
MonthComponent,
onEditName,
onSave,
onToggleCollapse,
onShowNewCategory,
}: IncomeGroupProps) {
const { IncomeGroupComponent: MonthComponent } = useBudgetComponents();
return (
<Row
collapsed={true}
@@ -52,7 +53,9 @@ export function IncomeGroup({
onToggleCollapse={onToggleCollapse}
onShowNewCategory={onShowNewCategory}
/>
<RenderMonths component={MonthComponent} args={{ group }} />
<RenderMonths>
{({ month }) => <MonthComponent month={month} group={group} />}
</RenderMonths>
</Row>
);
}

View File

@@ -1,4 +1,4 @@
import React, { type JSX } from 'react';
import React from 'react';
import { Trans } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -6,19 +6,18 @@ import { View } from '@actual-app/components/view';
import { RenderMonths } from './RenderMonths';
import { useBudgetComponents } from '.';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
type IncomeHeaderProps = {
MonthComponent?: () => JSX.Element;
onShowNewGroup: () => void;
};
export function IncomeHeader({
MonthComponent,
onShowNewGroup,
}: IncomeHeaderProps) {
export function IncomeHeader({ onShowNewGroup }: IncomeHeaderProps) {
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
const categoryExpandedState = categoryExpandedStatePref ?? 0;
const { IncomeHeaderComponent: MonthComponent } = useBudgetComponents();
return (
<View style={{ flexDirection: 'row', flex: 1 }}>
<View
@@ -32,10 +31,9 @@ export function IncomeHeader({
<Trans>Add group</Trans>
</Button>
</View>
<RenderMonths
component={MonthComponent}
style={{ border: 0, justifyContent: 'flex-end' }}
/>
<RenderMonths style={{ border: 0, justifyContent: 'flex-end' }}>
<MonthComponent />
</RenderMonths>
</View>
);
}

View File

@@ -1,10 +1,4 @@
// @ts-strict-ignore
import React, {
useContext,
type CSSProperties,
type ComponentType,
type JSX,
} from 'react';
import React, { type ReactNode, useContext, type CSSProperties } from 'react';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
@@ -16,35 +10,24 @@ import { MonthsContext } from './MonthsContext';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
type RenderMonthsProps = {
component?: ComponentType<{ month: string; editing: boolean }>;
editingMonth?: string;
args?: object;
children: ReactNode | (({ month }: { month: string }) => ReactNode);
style?: CSSProperties;
};
export function RenderMonths({
component: Component,
editingMonth,
args,
style,
}: RenderMonthsProps) {
export function RenderMonths({ children, style }: RenderMonthsProps) {
const { months } = useContext(MonthsContext);
return months.map((month, index) => {
const editing = editingMonth === month;
return (
<SheetNameProvider key={index} name={monthUtils.sheetForMonth(month)}>
<View
style={{
flex: 1,
borderLeft: '1px solid ' + theme.tableBorder,
...style,
}}
>
<Component month={month} editing={editing} {...args} />
</View>
</SheetNameProvider>
);
}) as unknown as JSX.Element;
return months.map((month, index) => (
<SheetNameProvider key={index} name={monthUtils.sheetForMonth(month)}>
<View
style={{
flex: 1,
borderLeft: '1px solid ' + theme.tableBorder,
...style,
}}
>
{typeof children === 'function' ? children({ month }) : children}
</View>
</SheetNameProvider>
));
}

View File

@@ -22,10 +22,8 @@ import { css } from '@emotion/css';
import { evalArithmetic } from 'loot-core/shared/arithmetic';
import * as monthUtils from 'loot-core/shared/months';
import { integerToCurrency, amountToInteger } from 'loot-core/shared/util';
import {
type CategoryGroupEntity,
type CategoryEntity,
} from 'loot-core/types/models';
import { type CategoryGroupMonthProps, type CategoryMonthProps } from '..';
import { BalanceMovementMenu } from './BalanceMovementMenu';
import { BudgetMenu } from './BudgetMenu';
@@ -152,14 +150,10 @@ export function IncomeHeaderMonth() {
);
}
type ExpenseGroupMonthProps = {
month: string;
group: CategoryGroupEntity;
};
export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
month,
group,
}: ExpenseGroupMonthProps) {
}: CategoryGroupMonthProps) {
const { id } = group;
return (
@@ -210,14 +204,6 @@ export const ExpenseGroupMonth = memo(function ExpenseGroupMonth({
);
});
type ExpenseCategoryMonthProps = {
month: string;
category: CategoryEntity;
editing: boolean;
onEdit: (id: CategoryEntity['id'] | null, month?: string) => void;
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
};
export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
month,
category,
@@ -225,7 +211,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
onEdit,
onBudgetAction,
onShowActivity,
}: ExpenseCategoryMonthProps) {
}: CategoryMonthProps) {
const { t } = useTranslation();
const budgetMenuTriggerRef = useRef(null);
@@ -550,20 +536,13 @@ export function IncomeGroupMonth({ month }: IncomeGroupMonthProps) {
);
}
type IncomeCategoryMonthProps = {
category: CategoryEntity;
isLast: boolean;
month: string;
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
};
export function IncomeCategoryMonth({
category,
isLast,
month,
onShowActivity,
onBudgetAction,
}: IncomeCategoryMonthProps) {
}: CategoryMonthProps) {
const incomeMenuTriggerRef = useRef(null);
const {
setMenuOpen: setIncomeMenuOpen,

View File

@@ -1,14 +1,17 @@
// @ts-strict-ignore
import React, { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import React, { useMemo, useState, useEffect, type ComponentType } from 'react';
import { styles } from '@actual-app/components/styles';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import * as monthUtils from 'loot-core/shared/months';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { DynamicBudgetTable } from './DynamicBudgetTable';
import { AutoSizingBudgetTable } from './DynamicBudgetTable';
import * as envelopeBudget from './envelope/EnvelopeBudgetComponents';
import { EnvelopeBudgetProvider } from './envelope/EnvelopeBudgetContext';
import * as trackingBudget from './tracking/TrackingBudgetComponents';
@@ -17,59 +20,21 @@ import { prewarmAllMonths, prewarmMonth } from './util';
import {
applyBudgetAction,
createCategory,
createCategoryGroup,
deleteCategory,
deleteCategoryGroup,
getCategories,
moveCategory,
moveCategoryGroup,
updateCategory,
updateCategoryGroup,
} from '@desktop-client/budget/budgetSlice';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useCategoryActions } from '@desktop-client/hooks/useCategoryActions';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import { useSpreadsheet } from '@desktop-client/hooks/useSpreadsheet';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
type TrackingReportComponents = {
SummaryComponent: typeof trackingBudget.BudgetSummary;
ExpenseCategoryComponent: typeof trackingBudget.ExpenseCategoryMonth;
ExpenseGroupComponent: typeof trackingBudget.ExpenseGroupMonth;
IncomeCategoryComponent: typeof trackingBudget.IncomeCategoryMonth;
IncomeGroupComponent: typeof trackingBudget.IncomeGroupMonth;
BudgetTotalsComponent: typeof trackingBudget.BudgetTotalsMonth;
IncomeHeaderComponent: typeof trackingBudget.IncomeHeaderMonth;
};
type EnvelopeBudgetComponents = {
SummaryComponent: typeof envelopeBudget.BudgetSummary;
ExpenseCategoryComponent: typeof envelopeBudget.ExpenseCategoryMonth;
ExpenseGroupComponent: typeof envelopeBudget.ExpenseGroupMonth;
IncomeCategoryComponent: typeof envelopeBudget.IncomeCategoryMonth;
IncomeGroupComponent: typeof envelopeBudget.IncomeGroupMonth;
BudgetTotalsComponent: typeof envelopeBudget.BudgetTotalsMonth;
IncomeHeaderComponent: typeof envelopeBudget.IncomeHeaderMonth;
};
type BudgetInnerProps = {
accountId?: string;
trackingComponents: TrackingReportComponents;
envelopeComponents: EnvelopeBudgetComponents;
};
function BudgetInner(props: BudgetInnerProps) {
const { t } = useTranslation();
export function Budget() {
const currentMonth = monthUtils.currentMonth();
const spreadsheet = useSpreadsheet();
const dispatch = useDispatch();
const navigate = useNavigate();
const [summaryCollapsed, setSummaryCollapsedPref] = useLocalPref(
'budget.summaryCollapsed',
);
@@ -111,7 +76,7 @@ function BudgetInner(props: BudgetInnerProps) {
setBounds({ start, end });
}
});
}, [props.accountId]);
}, []);
const onMonthSelect = async (month, numDisplayed) => {
setStartMonthPref(month);
@@ -145,116 +110,6 @@ function BudgetInner(props: BudgetInnerProps) {
}
};
const categoryNameAlreadyExistsNotification = name => {
dispatch(
addNotification({
notification: {
type: 'error',
message: t(
'Category “{{name}}” already exists in group (it may be hidden)',
{ name },
),
},
}),
);
};
const onSaveCategory = async category => {
const cats = await send('get-categories');
const exists =
cats.grouped
.filter(g => g.id === category.group)[0]
.categories.filter(
c => c.name.toUpperCase() === category.name.toUpperCase(),
)
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
.length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(category.name);
return;
}
if (category.id === 'new') {
dispatch(
createCategory({
name: category.name,
groupId: category.group,
isIncome: category.is_income,
isHidden: category.hidden,
}),
);
} else {
dispatch(updateCategory({ category }));
}
};
const onDeleteCategory = async id => {
const mustTransfer = await send('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: id,
onDelete: transferCategory => {
if (id !== transferCategory) {
dispatch(
deleteCategory({ id, transferId: transferCategory }),
);
}
},
},
},
}),
);
} else {
dispatch(deleteCategory({ id }));
}
};
const onSaveGroup = group => {
if (group.id === 'new') {
dispatch(createCategoryGroup({ name: group.name }));
} else {
dispatch(updateCategoryGroup({ group }));
}
};
const onDeleteGroup = async id => {
const group = categoryGroups.find(g => g.id === id);
let mustTransfer = false;
for (const category of group.categories) {
if (await send('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: id,
onDelete: transferCategory => {
dispatch(
deleteCategoryGroup({ id, transferId: transferCategory }),
);
},
},
},
}),
);
} else {
dispatch(deleteCategoryGroup({ id }));
}
};
const onApplyBudgetTemplatesInGroup = async categories => {
dispatch(
applyBudgetAction({
@@ -271,62 +126,19 @@ function BudgetInner(props: BudgetInnerProps) {
dispatch(applyBudgetAction({ month, type, args }));
};
const onShowActivity = (categoryId, month) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
op: 'is',
value: month,
options: { month: true },
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
categoryId,
},
});
};
const onReorderCategory = async sortInfo => {
const cats = await send('get-categories');
const moveCandidate = cats.list.filter(c => c.id === sortInfo.id)[0];
const exists =
cats.grouped
.filter(g => g.id === sortInfo.groupId)[0]
.categories.filter(
c => c.name.toUpperCase() === moveCandidate.name.toUpperCase(),
)
.filter(c => c.id !== moveCandidate.id).length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(moveCandidate.name);
return;
}
dispatch(
moveCategory({
id: sortInfo.id,
groupId: sortInfo.groupId,
targetId: sortInfo.targetId,
}),
);
};
const onReorderGroup = async sortInfo => {
dispatch(
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
);
};
const onToggleCollapse = () => {
setSummaryCollapsedPref(!summaryCollapsed);
};
const { trackingComponents, envelopeComponents } = props;
const {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
onReorderCategory,
onReorderGroup,
} = useCategoryActions();
if (!initialized || !categoryGroups) {
return null;
@@ -340,14 +152,12 @@ function BudgetInner(props: BudgetInnerProps) {
onBudgetAction={onBudgetAction}
onToggleSummaryCollapse={onToggleCollapse}
>
<DynamicBudgetTable
<AutoSizingBudgetTable
type={budgetType}
prewarmStartMonth={startMonth}
startMonth={startMonth}
monthBounds={bounds}
maxMonths={maxMonths}
// @ts-expect-error fix me
dataComponents={trackingComponents}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
@@ -368,14 +178,12 @@ function BudgetInner(props: BudgetInnerProps) {
onBudgetAction={onBudgetAction}
onToggleSummaryCollapse={onToggleCollapse}
>
<DynamicBudgetTable
<AutoSizingBudgetTable
type={budgetType}
prewarmStartMonth={startMonth}
startMonth={startMonth}
monthBounds={bounds}
maxMonths={maxMonths}
// @ts-expect-error fix me
dataComponents={envelopeComponents}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
@@ -393,13 +201,65 @@ function BudgetInner(props: BudgetInnerProps) {
return (
<SheetNameProvider name={monthUtils.sheetForMonth(startMonth)}>
<View style={{ flex: 1 }}>{table}</View>
{/*
In a previous iteration, the wrapper needs `overflow: hidden` for
some reason. Without it at certain dimensions the width/height
that autosizer gives us is slightly wrong, causing scrollbars to
appear. We might not need it anymore?
*/}
<View
style={{
...styles.page,
paddingLeft: 8,
paddingRight: 8,
overflow: 'hidden',
}}
>
<View style={{ flex: 1 }}>{table}</View>
</View>
</SheetNameProvider>
);
}
export function Budget() {
const trackingComponents = useMemo<TrackingReportComponents>(
export type BudgetSummaryProps = {
month: string;
};
export type CategoryMonthProps = {
month: string;
category: CategoryEntity;
editing: boolean;
isLast?: boolean;
onEdit: (id: CategoryEntity['id'] | null, month?: string) => void;
onBudgetAction: (month: string, action: string, arg: unknown) => void;
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
};
export type CategoryGroupMonthProps = {
month: string;
group: CategoryGroupEntity;
};
export type BudgetComponents = {
SummaryComponent: ComponentType<BudgetSummaryProps>;
ExpenseCategoryComponent: ComponentType<CategoryMonthProps>;
ExpenseGroupComponent: ComponentType<CategoryGroupMonthProps>;
IncomeCategoryComponent: ComponentType<CategoryMonthProps>;
IncomeGroupComponent: ComponentType<CategoryGroupMonthProps>;
BudgetTotalsComponent: ComponentType;
IncomeHeaderComponent: ComponentType;
};
export function useBudgetComponents(): BudgetComponents {
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
const envelopeComponents = useEnvelopeBudgetComponents();
const trackingComponents = useTrackingBudgetComponents();
return budgetType === 'envelope' ? envelopeComponents : trackingComponents;
}
function useTrackingBudgetComponents(): BudgetComponents {
return useMemo(
() => ({
SummaryComponent: trackingBudget.BudgetSummary,
ExpenseCategoryComponent: trackingBudget.ExpenseCategoryMonth,
@@ -409,10 +269,12 @@ export function Budget() {
BudgetTotalsComponent: trackingBudget.BudgetTotalsMonth,
IncomeHeaderComponent: trackingBudget.IncomeHeaderMonth,
}),
[trackingBudget],
[],
);
}
const envelopeComponents = useMemo<EnvelopeBudgetComponents>(
function useEnvelopeBudgetComponents(): BudgetComponents {
return useMemo(
() => ({
SummaryComponent: envelopeBudget.BudgetSummary,
ExpenseCategoryComponent: envelopeBudget.ExpenseCategoryMonth,
@@ -422,26 +284,6 @@ export function Budget() {
BudgetTotalsComponent: envelopeBudget.BudgetTotalsMonth,
IncomeHeaderComponent: envelopeBudget.IncomeHeaderMonth,
}),
[envelopeBudget],
);
// In a previous iteration, the wrapper needs `overflow: hidden` for
// some reason. Without it at certain dimensions the width/height
// that autosizer gives us is slightly wrong, causing scrollbars to
// appear. We might not need it anymore?
return (
<View
style={{
...styles.page,
paddingLeft: 8,
paddingRight: 8,
overflow: 'hidden',
}}
>
<BudgetInner
trackingComponents={trackingComponents}
envelopeComponents={envelopeComponents}
/>
</View>
[],
);
}

View File

@@ -24,10 +24,8 @@ import { css } from '@emotion/css';
import { evalArithmetic } from 'loot-core/shared/arithmetic';
import * as monthUtils from 'loot-core/shared/months';
import { integerToCurrency, amountToInteger } from 'loot-core/shared/util';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { type CategoryGroupMonthProps, type CategoryMonthProps } from '..';
import { BalanceMenu } from './BalanceMenu';
import { BudgetMenu } from './BudgetMenu';
@@ -149,14 +147,10 @@ export function IncomeHeaderMonth() {
);
}
type GroupMonthProps = {
month: string;
group: CategoryGroupEntity;
};
export const GroupMonth = memo(function GroupMonth({
month,
group,
}: GroupMonthProps) {
}: CategoryGroupMonthProps) {
const { id } = group;
return (
@@ -209,14 +203,6 @@ export const GroupMonth = memo(function GroupMonth({
);
});
type CategoryMonthProps = {
month: string;
category: CategoryEntity;
editing: boolean;
onEdit: (id: CategoryEntity['id'] | null, month?: string) => void;
onBudgetAction: (month: string, action: string, arg: unknown) => void;
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
};
export const CategoryMonth = memo(function CategoryMonth({
month,
category,

View File

@@ -0,0 +1,228 @@
import { useTranslation } from 'react-i18next';
import { send } from 'loot-core/platform/client/fetch';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { useCategories } from './useCategories';
import { useNavigate } from './useNavigate';
import {
createCategory,
createCategoryGroup,
deleteCategory,
deleteCategoryGroup,
moveCategory,
moveCategoryGroup,
updateCategory,
updateCategoryGroup,
} from '@desktop-client/budget/budgetSlice';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function useCategoryActions() {
const dispatch = useDispatch();
const { t } = useTranslation();
const navigate = useNavigate();
const { grouped: categoryGroups } = useCategories();
const categoryNameAlreadyExistsNotification = (
name: CategoryEntity['name'],
) => {
dispatch(
addNotification({
notification: {
type: 'error',
message: t(
'Category “{{name}}” already exists in group (it may be hidden)',
{ name },
),
},
}),
);
};
const onSaveCategory = async (category: CategoryEntity) => {
const { grouped: categoryGroups = [] } = await send('get-categories');
const group = categoryGroups.find(g => g.id === category.group);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === category.name.toUpperCase())
.filter(c => (category.id === 'new' ? true : c.id !== category.id))
.length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(category.name);
return;
}
if (category.id === 'new') {
dispatch(
createCategory({
name: category.name,
groupId: category.group,
isIncome: !!category.is_income,
isHidden: !!category.hidden,
}),
);
} else {
dispatch(updateCategory({ category }));
}
};
const onDeleteCategory = async (id: CategoryEntity['id']) => {
const mustTransfer = await send('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
category: id,
onDelete: transferCategory => {
if (id !== transferCategory) {
dispatch(
deleteCategory({ id, transferId: transferCategory }),
);
}
},
},
},
}),
);
} else {
dispatch(deleteCategory({ id }));
}
};
const onSaveGroup = (group: CategoryGroupEntity) => {
if (group.id === 'new') {
dispatch(createCategoryGroup({ name: group.name }));
} else {
dispatch(updateCategoryGroup({ group }));
}
};
const onDeleteGroup = async (id: CategoryGroupEntity['id']) => {
const group = categoryGroups.find(g => g.id === id);
if (!group) {
return;
}
const groupCategories = group.categories ?? [];
let mustTransfer = false;
for (const category of groupCategories) {
if (await send('must-category-transfer', { id: category.id })) {
mustTransfer = true;
break;
}
}
if (mustTransfer) {
dispatch(
pushModal({
modal: {
name: 'confirm-category-delete',
options: {
group: id,
onDelete: transferCategory => {
dispatch(
deleteCategoryGroup({ id, transferId: transferCategory }),
);
},
},
},
}),
);
} else {
dispatch(deleteCategoryGroup({ id }));
}
};
const onShowActivity = (categoryId: CategoryEntity['id'], month: string) => {
const filterConditions = [
{ field: 'category', op: 'is', value: categoryId, type: 'id' },
{
field: 'date',
op: 'is',
value: month,
options: { month: true },
type: 'date',
},
];
navigate('/accounts', {
state: {
goBack: true,
filterConditions,
categoryId,
},
});
};
const onReorderCategory = async (sortInfo: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
targetId: CategoryEntity['id'] | null;
}) => {
const { grouped: categoryGroups = [], list: categories = [] } =
await send('get-categories');
const moveCandidate = categories.find(c => c.id === sortInfo.id);
const group = categoryGroups.find(g => g.id === sortInfo.groupId);
if (!moveCandidate || !group) {
return;
}
const groupCategories = group.categories ?? [];
const exists =
groupCategories
.filter(c => c.name.toUpperCase() === moveCandidate.name.toUpperCase())
.filter(c => c.id !== moveCandidate.id).length > 0;
if (exists) {
categoryNameAlreadyExistsNotification(moveCandidate.name);
return;
}
dispatch(
moveCategory({
id: moveCandidate.id,
groupId: group.id,
targetId: sortInfo.targetId,
}),
);
};
const onReorderGroup = async (sortInfo: {
id: CategoryGroupEntity['id'];
targetId: CategoryGroupEntity['id'] | null;
}) => {
dispatch(
moveCategoryGroup({ id: sortInfo.id, targetId: sortInfo.targetId }),
);
};
return {
onSaveCategory,
onDeleteCategory,
onSaveGroup,
onDeleteGroup,
onShowActivity,
onReorderCategory,
onReorderGroup,
};
}

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Update types for budget table components + new hooks to reduce prop drilling