-
Joel Jeremy Marquez authored
* Format notes as clickable tags * Release notes * Fix tests - extract the handler to higher level component * Update colors * Fix filtering * Rename variables * Remove font weight * Cleanup style * Append note tag filters * Fix overlapping UI * Revert pill colors * Rename prop * Rename notes * Delete filter margin * Fix typecheck error * VRT + typecheck fix * Add matches op in rules + use it to match tags * Fix database types * Fix typecheck error * Fix typecheck * Move create_function call * VRT * Update tag regex * Escape regex input * Update tag regex * Use onApplyFilter * Update tag formatting * Fix tag formatting * Update regex * VRT * Update packages/desktop-client/src/components/modals/EditRule.jsx Co-authored-by:
Robert Dyer <rdyer@unl.edu> * VRT * Fix error * Fix filtered balance * VRT --------- Co-authored-by:
Robert Dyer <rdyer@unl.edu>
Joel Jeremy Marquez authored* Format notes as clickable tags * Release notes * Fix tests - extract the handler to higher level component * Update colors * Fix filtering * Rename variables * Remove font weight * Cleanup style * Append note tag filters * Fix overlapping UI * Revert pill colors * Rename prop * Rename notes * Delete filter margin * Fix typecheck error * VRT + typecheck fix * Add matches op in rules + use it to match tags * Fix database types * Fix typecheck error * Fix typecheck * Move create_function call * VRT * Update tag regex * Escape regex input * Update tag regex * Use onApplyFilter * Update tag formatting * Fix tag formatting * Update regex * VRT * Update packages/desktop-client/src/components/modals/EditRule.jsx Co-authored-by:
Robert Dyer <rdyer@unl.edu> * VRT * Fix error * Fix filtered balance * VRT --------- Co-authored-by:
Robert Dyer <rdyer@unl.edu>
index.tsx 13.13 KiB
// @ts-strict-ignore
import React, { memo, useContext, useMemo, useState, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import {
addNotification,
applyBudgetAction,
createCategory,
createGroup,
deleteCategory,
deleteGroup,
getCategories,
loadPrefs,
moveCategory,
moveCategoryGroup,
pushModal,
updateCategory,
updateGroup,
} from 'loot-core/src/client/actions';
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
import { send, listen } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months';
import { useCategories } from '../../hooks/useCategories';
import { useGlobalPref } from '../../hooks/useGlobalPref';
import { useLocalPref } from '../../hooks/useLocalPref';
import { useNavigate } from '../../hooks/useNavigate';
import { styles } from '../../style';
import { View } from '../common/View';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
import {
SWITCH_BUDGET_MESSAGE_TYPE,
TitlebarContext,
type TitlebarContextValue,
type TitlebarMessage,
} from '../Titlebar';
import { DynamicBudgetTable } from './DynamicBudgetTable';
import * as report from './report/ReportComponents';
import { ReportProvider } from './report/ReportContext';
import * as rollover from './rollover/RolloverComponents';
import { RolloverProvider } from './rollover/RolloverContext';
import { prewarmAllMonths, prewarmMonth, switchBudgetType } from './util';
type ReportComponents = {
SummaryComponent: typeof report.BudgetSummary;
ExpenseCategoryComponent: typeof report.ExpenseCategoryMonth;
ExpenseGroupComponent: typeof report.ExpenseGroupMonth;
IncomeCategoryComponent: typeof report.IncomeCategoryMonth;
IncomeGroupComponent: typeof report.IncomeGroupMonth;
BudgetTotalsComponent: typeof report.BudgetTotalsMonth;
IncomeHeaderComponent: typeof report.IncomeHeaderMonth;
};
type RolloverComponents = {
SummaryComponent: typeof RolloverBudgetSummary;
ExpenseCategoryComponent: typeof rollover.ExpenseCategoryMonth;
ExpenseGroupComponent: typeof rollover.ExpenseGroupMonth;
IncomeCategoryComponent: typeof rollover.IncomeCategoryMonth;
IncomeGroupComponent: typeof rollover.IncomeGroupMonth;
BudgetTotalsComponent: typeof rollover.BudgetTotalsMonth;
IncomeHeaderComponent: typeof rollover.IncomeHeaderMonth;
};
type BudgetInnerProps = {
accountId?: string;
reportComponents: ReportComponents;
rolloverComponents: RolloverComponents;
titlebar: TitlebarContextValue;
};
function BudgetInner(props: BudgetInnerProps) {
const currentMonth = monthUtils.currentMonth();
const spreadsheet = useSpreadsheet();
const dispatch = useDispatch();
const navigate = useNavigate();
const [summaryCollapsed, setSummaryCollapsedPref] = useLocalPref(
'budget.summaryCollapsed',
);
const [startMonthPref, setStartMonthPref] = useLocalPref('budget.startMonth');
const startMonth = startMonthPref || currentMonth;
const [bounds, setBounds] = useState({
start: startMonth,
end: startMonth,
});
const [budgetTypePref] = useLocalPref('budgetType');
const budgetType = budgetTypePref || 'rollover';
const [maxMonthsPref] = useGlobalPref('maxMonths');
const maxMonths = maxMonthsPref || 1;
const [initialized, setInitialized] = useState(false);
const { grouped: categoryGroups } = useCategories();
function loadCategories() {
dispatch(getCategories());
}
useEffect(() => {
const { titlebar } = props;
async function run() {
loadCategories();
const { start, end } = await send('get-budget-bounds');
setBounds({ start, end });
await prewarmAllMonths(
budgetType,
spreadsheet,
{ start, end },
startMonth,
);
setInitialized(true);
}
run();
const unlistens = [
listen('sync-event', ({ type, tables }) => {
if (
type === 'success' &&
(tables.includes('categories') ||
tables.includes('category_mapping') ||
tables.includes('category_groups'))
) {
loadCategories();
}
}),
listen('undo-event', ({ tables }) => {
if (tables.includes('categories')) {
loadCategories();
}
}),
titlebar.subscribe(onTitlebarEvent),
];
return () => {
unlistens.forEach(unlisten => unlisten());
};
}, []);
useEffect(() => {
send('get-budget-bounds').then(({ start, end }) => {
if (bounds.start !== start || bounds.end !== end) {
setBounds({ start, end });
}
});
}, [props.accountId]);
const onMonthSelect = async (month, numDisplayed) => {
setStartMonthPref(month);
const warmingMonth = month;
// We could be smarter about this, but this is a good start. We
// optimize for the case where users press the left/right button
// to move between months. This loads the month data all at once
// and "prewarms" the spreadsheet cache. This uses a simple
// heuristic that will fail if the user clicks an arbitrary month,
// but it will just load in some unnecessary data.
if (month < startMonth) {
// pre-warm prev month
await prewarmMonth(
budgetType,
spreadsheet,
monthUtils.subMonths(month, 1),
);
} else if (month > startMonth) {
// pre-warm next month
await prewarmMonth(
budgetType,
spreadsheet,
monthUtils.addMonths(month, numDisplayed),
);
}
if (warmingMonth === month) {
setStartMonthPref(month);
}
};
const categoryNameAlreadyExistsNotification = name => {
dispatch(
addNotification({
type: 'error',
message: `Category ‘${name}’ already exists in group (May be Hidden)`,
}),
);
};
const onSaveCategory = async category => {
const cats = await send('get-categories');
const exists =
cats.grouped
.filter(g => g.id === category.cat_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(
category.name,
category.cat_group,
category.is_income,
category.hidden,
),
);
} else {
dispatch(updateCategory(category));
}
};
const onDeleteCategory = async id => {
const mustTransfer = await send('must-category-transfer', { id });
if (mustTransfer) {
dispatch(
pushModal('confirm-category-delete', {
category: id,
onDelete: transferCategory => {
if (id !== transferCategory) {
dispatch(deleteCategory(id, transferCategory));
}
},
}),
);
} else {
dispatch(deleteCategory(id));
}
};
const onSaveGroup = group => {
if (group.id === 'new') {
dispatch(createGroup(group.name));
} else {
dispatch(updateGroup(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('confirm-category-delete', {
group: id,
onDelete: transferCategory => {
dispatch(deleteGroup(id, transferCategory));
},
}),
);
} else {
dispatch(deleteGroup(id));
}
};
const onBudgetAction = (month, type, args) => {
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(sortInfo.id, sortInfo.groupId, sortInfo.targetId));
};
const onReorderGroup = async sortInfo => {
dispatch(moveCategoryGroup(sortInfo.id, sortInfo.targetId));
};
const onToggleCollapse = () => {
setSummaryCollapsedPref(!summaryCollapsed);
};
const onTitlebarEvent = async ({ type, payload }: TitlebarMessage) => {
switch (type) {
case SWITCH_BUDGET_MESSAGE_TYPE: {
await switchBudgetType(
payload.newBudgetType,
spreadsheet,
bounds,
startMonth,
async () => {
dispatch(loadPrefs());
},
);
break;
}
default:
}
};
const { reportComponents, rolloverComponents } = props;
if (!initialized || !categoryGroups) {
return null;
}
let table;
if (budgetType === 'report') {
table = (
<ReportProvider
summaryCollapsed={summaryCollapsed}
onBudgetAction={onBudgetAction}
onToggleSummaryCollapse={onToggleCollapse}
>
<DynamicBudgetTable
type={budgetType}
prewarmStartMonth={startMonth}
startMonth={startMonth}
monthBounds={bounds}
maxMonths={maxMonths}
dataComponents={reportComponents}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onReorderCategory={onReorderCategory}
onReorderGroup={onReorderGroup}
/>
</ReportProvider>
);
} else {
table = (
<RolloverProvider
summaryCollapsed={summaryCollapsed}
onBudgetAction={onBudgetAction}
onToggleSummaryCollapse={onToggleCollapse}
>
<DynamicBudgetTable
type={budgetType}
prewarmStartMonth={startMonth}
startMonth={startMonth}
monthBounds={bounds}
maxMonths={maxMonths}
dataComponents={rolloverComponents}
onMonthSelect={onMonthSelect}
onDeleteCategory={onDeleteCategory}
onDeleteGroup={onDeleteGroup}
onSaveCategory={onSaveCategory}
onSaveGroup={onSaveGroup}
onBudgetAction={onBudgetAction}
onShowActivity={onShowActivity}
onReorderCategory={onReorderCategory}
onReorderGroup={onReorderGroup}
/>
</RolloverProvider>
);
}
return (
<NamespaceContext.Provider value={monthUtils.sheetForMonth(startMonth)}>
<View style={{ flex: 1 }}>{table}</View>
</NamespaceContext.Provider>
);
}
const RolloverBudgetSummary = memo<{ month: string }>(props => {
return <rollover.BudgetSummary {...props} />;
});
RolloverBudgetSummary.displayName = 'RolloverBudgetSummary';
export function Budget() {
const titlebar = useContext(TitlebarContext);
const reportComponents = useMemo<ReportComponents>(
() => ({
SummaryComponent: report.BudgetSummary,
ExpenseCategoryComponent: report.ExpenseCategoryMonth,
ExpenseGroupComponent: report.ExpenseGroupMonth,
IncomeCategoryComponent: report.IncomeCategoryMonth,
IncomeGroupComponent: report.IncomeGroupMonth,
BudgetTotalsComponent: report.BudgetTotalsMonth,
IncomeHeaderComponent: report.IncomeHeaderMonth,
}),
[report],
);
const rolloverComponents = useMemo<RolloverComponents>(
() => ({
SummaryComponent: RolloverBudgetSummary,
ExpenseCategoryComponent: rollover.ExpenseCategoryMonth,
ExpenseGroupComponent: rollover.ExpenseGroupMonth,
IncomeCategoryComponent: rollover.IncomeCategoryMonth,
IncomeGroupComponent: rollover.IncomeGroupMonth,
BudgetTotalsComponent: rollover.BudgetTotalsMonth,
IncomeHeaderComponent: rollover.IncomeHeaderMonth,
}),
[rollover],
);
// 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
reportComponents={reportComponents}
rolloverComponents={rolloverComponents}
titlebar={titlebar}
/>
</View>
);
}