♻️ (typescript) Convert BudgetCategories to TypeScript (#5961)

This commit is contained in:
Matiss Janis Aboltins
2025-11-05 22:15:38 +00:00
committed by GitHub
parent 1a845583ef
commit b690302998
10 changed files with 165 additions and 67 deletions

View File

@@ -1,9 +1,19 @@
import React, { memo, useState, useMemo } from 'react';
import React, {
memo,
useState,
useMemo,
type ComponentPropsWithoutRef,
} from 'react';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { ExpenseCategory } from './ExpenseCategory';
import { ExpenseGroup } from './ExpenseGroup';
import { IncomeCategory } from './IncomeCategory';
@@ -13,11 +23,66 @@ import { SidebarCategory } from './SidebarCategory';
import { SidebarGroup } from './SidebarGroup';
import { separateGroups } from './util';
import { DropHighlightPosContext } from '@desktop-client/components/sort';
import {
DropHighlightPosContext,
type DragState,
type OnDropCallback,
} from '@desktop-client/components/sort';
import { Row } from '@desktop-client/components/table';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
export const BudgetCategories = memo(
type BudgetItem =
| { type: 'new-group' }
| { type: 'new-category' }
| { type: 'expense-group'; value: CategoryGroupEntity }
| {
type: 'expense-category';
value: CategoryEntity;
group: CategoryGroupEntity;
}
| { type: 'income-separator' }
| { type: 'income-group'; value: CategoryGroupEntity }
| { type: 'income-category'; value: CategoryEntity };
type LocalDragState =
| DragState<CategoryEntity>
| DragState<CategoryGroupEntity>
| null;
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;
onDeleteCategory: (id: CategoryEntity['id']) => void;
onDeleteGroup?: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup?: (categoryIds: CategoryEntity['id'][]) => void;
onReorderCategory: OnDropCallback;
onReorderGroup: OnDropCallback;
};
export const BudgetCategories = memo<BudgetCategoriesProps>(
({
categoryGroups,
editingCell,
@@ -37,27 +102,31 @@ export const BudgetCategories = memo(
const [collapsedGroupIds = [], setCollapsedGroupIdsPref] =
useLocalPref('budget.collapsed');
const [showHiddenCategories] = useLocalPref('budget.showHiddenCategories');
function onCollapse(value) {
function onCollapse(value: Array<CategoryGroupEntity['id']>) {
setCollapsedGroupIdsPref(value);
}
const [isAddingGroup, setIsAddingGroup] = useState(false);
const [newCategoryForGroup, setNewCategoryForGroup] = useState(null);
const items = useMemo(() => {
const [newCategoryForGroup, setNewCategoryForGroup] = useState<
string | null
>(null);
const items: BudgetItem[] = useMemo(() => {
const [expenseGroups, incomeGroup] = separateGroups(categoryGroups);
let items = Array.prototype.concat.apply(
let items: BudgetItem[] = Array.prototype.concat.apply(
[],
expenseGroups.map(group => {
if (group.hidden && !showHiddenCategories) {
return [];
}
const groupCategories = group.categories.filter(
const groupCategories = group.categories?.filter(
cat => showHiddenCategories || !cat.hidden,
);
const items = [{ type: 'expense-group', value: { ...group } }];
const items: BudgetItem[] = [
{ type: 'expense-group', value: { ...group } },
];
if (newCategoryForGroup === group.id) {
items.push({ type: 'new-category' });
@@ -67,12 +136,14 @@ export const BudgetCategories = memo(
...items,
...(collapsedGroupIds.includes(group.id)
? []
: groupCategories
).map(cat => ({
type: 'expense-category',
value: cat,
group,
})),
: groupCategories || []
).map(
(cat): BudgetItem => ({
type: 'expense-category',
value: cat,
group,
}),
),
];
}),
);
@@ -82,22 +153,30 @@ export const BudgetCategories = memo(
}
if (incomeGroup) {
items = items.concat(
[
{ type: 'income-separator' },
{ type: 'income-group', value: incomeGroup },
newCategoryForGroup === incomeGroup.id && { type: 'new-category' },
...(collapsedGroupIds.includes(incomeGroup.id)
? []
: incomeGroup.categories.filter(
cat => showHiddenCategories || !cat.hidden,
)
).map(cat => ({
const incomeCategoryItems: BudgetItem[] = [
{ type: 'income-separator' },
{ type: 'income-group', value: incomeGroup },
];
if (newCategoryForGroup === incomeGroup.id) {
incomeCategoryItems.push({ type: 'new-category' });
}
incomeCategoryItems.push(
...(collapsedGroupIds.includes(incomeGroup.id)
? []
: incomeGroup.categories?.filter(
cat => showHiddenCategories || !cat.hidden,
) || []
).map(
(cat): BudgetItem => ({
type: 'income-category',
value: cat,
})),
].filter(x => x),
}),
),
);
items = items.concat(incomeCategoryItems);
}
return items;
@@ -109,15 +188,20 @@ export const BudgetCategories = memo(
showHiddenCategories,
]);
const [dragState, setDragState] = useState(null);
const [savedCollapsed, setSavedCollapsed] = useState(null);
const [dragState, setDragState] = useState<LocalDragState>(null);
const [savedCollapsed, setSavedCollapsed] = useState<Array<
CategoryGroupEntity['id']
> | null>(null);
// TODO: If we turn this into a reducer, we could probably memoize
// each item in the list for better perf
function onDragChange(newDragState) {
function onDragChange(
newDragState: DragState<CategoryEntity> | DragState<CategoryGroupEntity>,
) {
const { state } = newDragState;
if (state === 'start-preview') {
// @ts-expect-error fix me
setDragState({
type: newDragState.type,
item: newDragState.item,
@@ -131,19 +215,13 @@ export const BudgetCategories = memo(
});
setSavedCollapsed(collapsedGroupIds);
}
} else if (state === 'hover') {
setDragState({
...dragState,
hoveredId: newDragState.id,
hoveredPos: newDragState.pos,
});
} else if (state === 'end') {
setDragState(null);
onCollapse(savedCollapsed || []);
}
}
function onToggleCollapse(id) {
function onToggleCollapse(id: CategoryGroupEntity['id']) {
if (collapsedGroupIds.includes(id)) {
onCollapse(collapsedGroupIds.filter(id_ => id_ !== id));
} else {
@@ -159,14 +237,14 @@ export const BudgetCategories = memo(
setIsAddingGroup(false);
}
function _onSaveGroup(group) {
function _onSaveGroup(group: CategoryGroupEntity) {
onSaveGroup?.(group);
if (group.id === 'new') {
onHideNewGroup();
}
}
function onShowNewCategory(groupId) {
function onShowNewCategory(groupId: CategoryGroupEntity['id']) {
onCollapse(collapsedGroupIds.filter(c => c !== groupId));
setNewCategoryForGroup(groupId);
}
@@ -175,7 +253,7 @@ export const BudgetCategories = memo(
setNewCategoryForGroup(null);
}
function _onSaveCategory(category) {
function _onSaveCategory(category: CategoryEntity) {
onSaveCategory?.(category);
if (category.id === 'new') {
onHideNewCategory();
@@ -203,6 +281,7 @@ export const BudgetCategories = memo(
>
<SidebarGroup
group={{ id: 'new', name: '' }}
collapsed={false}
editing={true}
onSave={_onSaveGroup}
onHideNewGroup={onHideNewGroup}
@@ -215,18 +294,20 @@ export const BudgetCategories = memo(
content = (
<Row>
<SidebarCategory
innerRef={null}
category={{
name: '',
group: newCategoryForGroup,
group: newCategoryForGroup!,
is_income:
newCategoryForGroup ===
categoryGroups.find(g => g.is_income).id,
categoryGroups.find(g => g.is_income)?.id,
id: 'new',
}}
editing={true}
onSave={_onSaveCategory}
onDelete={async () => {}}
onHideNewCategory={onHideNewCategory}
onEditName={onEditName}
onEditName={onEditName!}
/>
</Row>
);
@@ -293,10 +374,10 @@ export const BudgetCategories = memo(
editingCell={editingCell}
MonthComponent={dataComponents.IncomeGroupComponent}
collapsed={collapsedGroupIds.includes(item.value.id)}
onEditName={onEditName}
onEditName={onEditName!}
onSave={_onSaveGroup}
onToggleCollapse={onToggleCollapse}
onShowNewCategory={onShowNewCategory}
onShowNewCategory={onShowNewCategory!}
/>
);
break;
@@ -307,7 +388,7 @@ export const BudgetCategories = memo(
editingCell={editingCell}
isLast={idx === items.length - 1}
MonthComponent={dataComponents.IncomeCategoryComponent}
onEditName={onEditName}
onEditName={onEditName!}
onEditMonth={onEditMonth}
onSave={_onSaveCategory}
onDelete={onDeleteCategory}
@@ -319,6 +400,7 @@ export const BudgetCategories = memo(
);
break;
default:
// @ts-expect-error Error is expected here because "item.type" is "never"
throw new Error('Unknown item type: ' + item.type);
}
@@ -328,7 +410,7 @@ export const BudgetCategories = memo(
return (
<DropHighlightPosContext.Provider
key={
item.value
'value' in item
? item.value.id
: item.type === 'income-separator'
? 'separator'
@@ -338,9 +420,11 @@ export const BudgetCategories = memo(
>
<View
style={
!dragState && {
':hover': { backgroundColor: theme.tableBackground },
}
dragState
? {}
: {
':hover': { backgroundColor: theme.tableBackground },
}
}
>
{content}

View File

@@ -1,5 +1,6 @@
import React, {
type ComponentPropsWithoutRef,
type ComponentProps,
type KeyboardEvent,
useMemo,
useState,
@@ -45,12 +46,12 @@ type BudgetTableProps = {
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: (groupId: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup: (categoryIds: CategoryEntity['id'][]) => void;
onReorderCategory: (params: {
id: CategoryEntity['id'];
groupId?: CategoryGroupEntity['id'];
@@ -297,7 +298,6 @@ export function BudgetTable(props: BudgetTableProps) {
>
<SchedulesProvider query={schedulesQuery}>
<BudgetCategories
// @ts-expect-error Fix when migrating BudgetCategories to ts
categoryGroups={categoryGroups}
editingCell={editing}
dataComponents={dataComponents}

View File

@@ -27,15 +27,15 @@ type ExpenseCategoryProps = {
cat: CategoryEntity;
categoryGroup?: CategoryGroupEntity;
editingCell: { id: string; cell: string } | null;
dragState: DragState<CategoryEntity>;
dragState: DragState<CategoryEntity> | DragState<CategoryGroupEntity> | null;
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
onEditName?: ComponentProps<typeof SidebarCategory>['onEditName'];
onEditMonth?: (id: string, month: string) => void;
onEditMonth?: (id: CategoryEntity['id'], month: string) => void;
onSave?: ComponentProps<typeof SidebarCategory>['onSave'];
onDelete?: ComponentProps<typeof SidebarCategory>['onDelete'];
onDragChange: OnDragChangeCallback<CategoryEntity>;
onBudgetAction: (month: number, action: string, arg: unknown) => void;
onShowActivity: (id: string, month: string) => void;
onBudgetAction: (month: string, action: string, arg: unknown) => void;
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
onReorder: OnDropCallback;
};

View File

@@ -4,6 +4,11 @@ import React, { type ComponentProps } from 'react';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import {
type CategoryEntity,
type CategoryGroupEntity,
} from 'loot-core/types/models';
import { RenderMonths } from './RenderMonths';
import { SidebarGroup } from './SidebarGroup';
@@ -22,7 +27,7 @@ type ExpenseGroupProps = {
group: ComponentProps<typeof SidebarGroup>['group'];
collapsed: boolean;
editingCell: { id: string; cell: string } | null;
dragState: DragState<ComponentProps<typeof SidebarGroup>['group']>;
dragState: DragState<CategoryEntity> | DragState<CategoryGroupEntity> | null;
MonthComponent: ComponentProps<typeof RenderMonths>['component'];
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];

View File

@@ -16,7 +16,7 @@ type IncomeGroupProps = {
collapsed: boolean;
MonthComponent: () => JSX.Element;
onEditName: (id: CategoryGroupEntity['id']) => void;
onSave: (group: CategoryGroupEntity) => Promise<void>;
onSave: (group: CategoryGroupEntity) => void;
onToggleCollapse: (id: CategoryGroupEntity['id']) => void;
onShowNewCategory: (groupId: CategoryGroupEntity['id']) => void;
};

View File

@@ -34,7 +34,7 @@ type SidebarCategoryProps = {
isLast?: boolean;
onEditName: (id: CategoryEntity['id']) => void;
onSave: (category: CategoryEntity) => void;
onDelete: (id: CategoryEntity['id']) => Promise<void>;
onDelete: (id: CategoryEntity['id']) => void;
onHideNewCategory?: () => void;
};

View File

@@ -30,8 +30,8 @@ type SidebarGroupProps = {
innerRef?: RefCallback<HTMLDivElement>;
style?: CSSProperties;
onEdit?: (id: CategoryGroupEntity['id']) => void;
onSave?: (group: CategoryGroupEntity) => Promise<void>;
onDelete?: (id: CategoryGroupEntity['id']) => Promise<void>;
onSave?: (group: CategoryGroupEntity) => void;
onDelete?: (id: CategoryGroupEntity['id']) => void;
onApplyBudgetTemplatesInGroup?: (
categories: Array<CategoryEntity['id']>,
) => void;

View File

@@ -346,6 +346,7 @@ function BudgetInner(props: BudgetInnerProps) {
startMonth={startMonth}
monthBounds={bounds}
maxMonths={maxMonths}
// @ts-expect-error fix me
dataComponents={trackingComponents}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
@@ -373,6 +374,7 @@ function BudgetInner(props: BudgetInnerProps) {
startMonth={startMonth}
monthBounds={bounds}
maxMonths={maxMonths}
// @ts-expect-error fix me
dataComponents={envelopeComponents}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}

View File

@@ -76,14 +76,14 @@ export function useDraggable<T>({
export type OnDropCallback = (
id: string,
dropPos: DropPosition,
targetId: unknown,
targetId: string,
) => Promise<void> | void;
type OnLongHoverCallback = () => Promise<void> | void;
type UseDroppableArgs = {
types: string | string[];
id: unknown;
id: string;
onDrop: OnDropCallback;
onLongHover?: OnLongHoverCallback;
};
@@ -95,7 +95,7 @@ export function useDroppable<T extends { id: string }>({
onLongHover,
}: UseDroppableArgs) {
const ref = useRef(null);
const [dropPos, setDropPos] = useState<DropPosition>(null);
const [dropPos, setDropPos] = useState<DropPosition | null>(null);
const [{ isOver }, dropRef] = useDrop<
{ item: T },
@@ -137,7 +137,7 @@ export function useDroppable<T extends { id: string }>({
};
}
type ItemPosition = 'first' | 'last';
type ItemPosition = 'first' | 'last' | null;
export const DropHighlightPosContext: Context<ItemPosition> =
createContext(null);

View File

@@ -0,0 +1,7 @@
---
category: Maintenance
authors: [MatissJanis]
---
Convert BudgetCategories component to TypeScript.