Add sorting option to custom reports (#4141)

* support sorting data in custom reports

* disable on unsupported report types

* db migration

* note

* typecheck

* split out sorting function and support complete sorting of nested data

* Update VRT

* fix defaults

* Update VRT

* always allow sorting on data tables

* Update VRT

* coderabbit

* migration: populate sort_by for existing reports

* automagically reverse sort direction, add options for alphabetical and budget sort order

* Update VRT

* fix migration

* default sorting options for different report types

* revert vrt

* Update VRT

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Matt Fiddaman
2025-01-13 22:11:51 +00:00
committed by GitHub
parent 629b001c01
commit bec841932d
30 changed files with 180 additions and 21 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -23,6 +23,7 @@ export const defaultReport: CustomReportEntity = {
groupBy: 'Category',
interval: 'Monthly',
balanceType: 'Payment',
sortBy: 'Descending',
showEmpty: false,
showOffBudget: false,
showHiddenCategories: false,
@@ -49,6 +50,13 @@ const groupByOptions = [
{ description: 'Interval' },
];
const sortByOptions = [
{ description: t('Ascending'), format: 'asc' as const },
{ description: t('Descending'), format: 'desc' as const },
{ description: t('Name'), format: 'name' as const },
{ description: t('Budget'), format: 'budget' as const },
];
export type dateRangeProps = {
description: string;
name: number | string;
@@ -198,6 +206,10 @@ export const ReportOptions = {
balanceTypeMap: new Map(
balanceTypeOptions.map(item => [item.description, item.format]),
),
sortBy: sortByOptions,
sortByMap: new Map(
sortByOptions.map(item => [item.description, item.format]),
),
dateRange: dateRangeOptions,
dateRangeMap: new Map(
dateRangeOptions.map(item => [item.description, item.name]),

View File

@@ -39,6 +39,7 @@ type ReportSidebarProps = {
setGroupBy: (value: string) => void;
setInterval: (value: string) => void;
setBalanceType: (value: string) => void;
setSortBy: (value: string) => void;
setMode: (value: string) => void;
setIsDateStatic: (value: boolean) => void;
setShowEmpty: (value: boolean) => void;
@@ -72,6 +73,7 @@ export function ReportSidebar({
setGroupBy,
setInterval,
setBalanceType,
setSortBy,
setMode,
setIsDateStatic,
setShowEmpty,
@@ -141,6 +143,12 @@ export function ReportSidebar({
setBalanceType(cond);
};
const onChangeSortBy = (cond: string) => {
setSessionReport('sortBy', cond);
onReportChange({ type: 'modify' });
setSortBy(cond);
};
const rangeOptions = useMemo(() => {
const options: SelectOption[] = ReportOptions.dateRange
.filter(f => f[customReportItems.interval as keyof dateRangeProps])
@@ -153,6 +161,15 @@ export function ReportSidebar({
return options;
}, [customReportItems, dateRangeLine]);
const disableSort =
customReportItems.graphType !== 'TableGraph' &&
(customReportItems.groupBy === 'Interval' ||
(disabledList?.mode
?.find(m => m.description === customReportItems.mode)
?.graphs.find(g => g.description === customReportItems.graphType)
?.disableSort ??
false));
return (
<View
style={{
@@ -266,6 +283,30 @@ export function ReportSidebar({
disabledKeys={[]}
/>
</View>
{!disableSort && (
<View
style={{
flexDirection: 'row',
padding: 5,
alignItems: 'center',
}}
>
<Text style={{ width: 50, textAlign: 'right', marginRight: 5 }}>
{t('Sort:')}
</Text>
<Select
value={customReportItems.sortBy}
onChange={e => onChangeSortBy(e)}
options={ReportOptions.sortBy.map(option => [
option.description,
option.description,
])}
disabledKeys={disabledItems('sort')}
/>
</View>
)}
<View
style={{
flexDirection: 'row',

View File

@@ -48,8 +48,10 @@ type graphOptions = {
defaultSplit: string;
disabledType: string[];
defaultType: string;
defaultSort: string;
disableLegend?: boolean;
disableLabel?: boolean;
disableSort?: boolean;
};
const totalGraphOptions: graphOptions[] = [
{
@@ -60,6 +62,7 @@ const totalGraphOptions: graphOptions[] = [
defaultType: 'Payment',
disableLegend: true,
disableLabel: true,
defaultSort: 'Budget',
},
{
description: 'BarGraph',
@@ -67,6 +70,7 @@ const totalGraphOptions: graphOptions[] = [
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
defaultSort: 'Descending',
},
{
description: 'AreaGraph',
@@ -75,6 +79,8 @@ const totalGraphOptions: graphOptions[] = [
disabledType: [],
defaultType: 'Payment',
disableLegend: true,
disableSort: true,
defaultSort: 'Descending',
},
{
description: 'DonutGraph',
@@ -82,6 +88,7 @@ const totalGraphOptions: graphOptions[] = [
defaultSplit: 'Category',
disabledType: ['Net'],
defaultType: 'Payment',
defaultSort: 'Descending',
},
];
@@ -94,6 +101,8 @@ const timeGraphOptions: graphOptions[] = [
defaultType: 'Payment',
disableLegend: true,
disableLabel: true,
disableSort: true,
defaultSort: 'Descending',
},
{
description: 'StackedBarGraph',
@@ -101,6 +110,8 @@ const timeGraphOptions: graphOptions[] = [
defaultSplit: 'Category',
disabledType: [],
defaultType: 'Payment',
disableSort: true,
defaultSort: 'Descending',
},
{
description: 'LineGraph',
@@ -110,6 +121,8 @@ const timeGraphOptions: graphOptions[] = [
defaultType: 'Payment',
disableLegend: false,
disableLabel: true,
disableSort: true,
defaultSort: 'Descending',
},
];
@@ -169,7 +182,7 @@ export function disabledLegendLabel(
export function defaultsGraphList(
item: string,
newGraph: string,
type: 'defaultSplit' | 'defaultType',
type: 'defaultSplit' | 'defaultType' | 'defaultSort',
) {
const graphList = modeOptions.find(d => d.description === item);
if (!graphList) {

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useMemo, useState, type CSSProperties } from 'react';
import React, { useState, type CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { css } from '@emotion/css';
@@ -203,12 +203,6 @@ export function BarGraph({
const leftMargin = Math.abs(largestValue) > 1000000 ? 20 : 0;
// Sort the data in the bar chart
const unsortedData = data[splitData];
const sortedData = useMemo(() => {
return unsortedData.sort((a, b) => a[balanceTypeOp] - b[balanceTypeOp]);
}, [unsortedData, balanceTypeOp]);
return (
<Container
style={{
@@ -225,7 +219,7 @@ export function BarGraph({
width={width}
height={height}
stackOffset="sign"
data={sortedData}
data={data[splitData]}
style={{ cursor: pointer }}
margin={{
top: labelsMargin,

View File

@@ -241,17 +241,13 @@ export function DonutGraph({
const [activeIndex, setActiveIndex] = useState(0);
// Sort the data in the pie chart
const unsortedData = data[splitData];
const sortedData = unsortedData.slice().sort((a, b) => getVal(b) - getVal(a));
return (
<Container style={style}>
{(width, height) => {
const compact = height <= 300 || width <= 300;
return (
sortedData && (
data[splitData] && (
<ResponsiveContainer>
<div>
{!compact && <div style={{ marginTop: '15px' }} />}
@@ -272,7 +268,7 @@ export function DonutGraph({
dataKey={val => getVal(val)}
nameKey={yAxis}
isAnimationActive={false}
data={sortedData}
data={data[splitData]}
innerRadius={Math.min(width, height) * 0.2}
fill="#8884d8"
labelLine={false}

View File

@@ -12,6 +12,7 @@ import { amountToCurrency } from 'loot-core/src/shared/util';
import { type CategoryEntity } from 'loot-core/types/models/category';
import {
type balanceTypeOpType,
type sortByOpType,
type CustomReportEntity,
type DataEntity,
} from 'loot-core/types/models/reports';
@@ -230,6 +231,8 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
const [groupBy, setGroupBy] = useState(loadReport.groupBy);
const [interval, setInterval] = useState(loadReport.interval);
const [balanceType, setBalanceType] = useState(loadReport.balanceType);
const [sortBy, setSortBy] = useState(loadReport.sortBy);
const [showEmpty, setShowEmpty] = useState(loadReport.showEmpty);
const [showOffBudget, setShowOffBudget] = useState(loadReport.showOffBudget);
const [includeCurrentInterval, setIncludeCurrentInterval] = useState(
@@ -359,6 +362,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
const balanceTypeOp: balanceTypeOpType =
ReportOptions.balanceTypeMap.get(balanceType) || 'totalDebts';
const sortByOp: sortByOpType = ReportOptions.sortByMap.get(sortBy) || 'desc';
const payees = usePayees();
const accounts = useAccounts();
@@ -381,6 +385,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
showHiddenCategories,
showUncategorized,
balanceTypeOp,
sortByOp,
firstDayOfWeekIdx,
});
}, [
@@ -395,6 +400,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
showOffBudget,
showHiddenCategories,
showUncategorized,
sortByOp,
firstDayOfWeekIdx,
]);
@@ -414,6 +420,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
showUncategorized,
groupBy,
balanceTypeOp,
sortByOp,
payees,
accounts,
graphType,
@@ -435,6 +442,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
showOffBudget,
showHiddenCategories,
showUncategorized,
sortByOp,
graphType,
firstDayOfWeekIdx,
]);
@@ -454,6 +462,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
groupBy,
interval,
balanceType,
sortBy,
showEmpty,
showOffBudget,
showHiddenCategories,
@@ -533,6 +542,12 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
setSessionReport('balanceType', cond);
setBalanceType(cond);
}
const defaultSort = defaultsGraphList(mode, chooseGraph, 'defaultSort');
if (defaultSort) {
setSessionReport('sortBy', defaultSort);
setSortBy(defaultSort);
}
};
const isItemDisabled = (type: string) => {
@@ -592,6 +607,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
setGroupBy(input.groupBy);
setInterval(input.interval);
setBalanceType(input.balanceType);
setSortBy(input.sortBy);
setShowEmpty(input.showEmpty);
setShowOffBudget(input.showOffBudget);
setShowHiddenCategories(input.showHiddenCategories);
@@ -722,6 +738,7 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
setGroupBy={setGroupBy}
setInterval={setInterval}
setBalanceType={setBalanceType}
setSortBy={setSortBy}
setMode={setMode}
setIsDateStatic={setIsDateStatic}
setShowEmpty={setShowEmpty}

View File

@@ -14,6 +14,7 @@ import {
} from 'loot-core/src/types/models';
import {
type balanceTypeOpType,
type sortByOpType,
type DataEntity,
type GroupedEntity,
type IntervalEntity,
@@ -33,6 +34,7 @@ import { filterEmptyRows } from './filterEmptyRows';
import { filterHiddenItems } from './filterHiddenItems';
import { makeQuery } from './makeQuery';
import { recalculate } from './recalculate';
import { sortData } from './sortData';
export type createCustomSpreadsheetProps = {
startDate: string;
@@ -47,6 +49,7 @@ export type createCustomSpreadsheetProps = {
showUncategorized: boolean;
groupBy?: string;
balanceTypeOp?: balanceTypeOpType;
sortByOp?: sortByOpType;
payees?: PayeeEntity[];
accounts?: AccountEntity[];
graphType?: string;
@@ -67,6 +70,7 @@ export function createCustomSpreadsheet({
showUncategorized,
groupBy = '',
balanceTypeOp = 'totalDebts',
sortByOp = 'desc',
payees = [],
accounts = [],
graphType,
@@ -281,8 +285,12 @@ export function createCustomSpreadsheet({
balanceTypeOp,
);
const sortedCalcDataFiltered = [...calcDataFiltered].sort(
sortData({ balanceTypeOp, sortByOp }),
);
setData({
data: calcDataFiltered,
data: sortedCalcDataFiltered,
intervalData,
legend,
startDate,

View File

@@ -14,6 +14,7 @@ import { type createCustomSpreadsheetProps } from './custom-spreadsheet';
import { filterEmptyRows } from './filterEmptyRows';
import { makeQuery } from './makeQuery';
import { recalculate } from './recalculate';
import { sortData } from './sortData';
export function createGroupedSpreadsheet({
startDate,
@@ -27,6 +28,7 @@ export function createGroupedSpreadsheet({
showHiddenCategories,
showUncategorized,
balanceTypeOp,
sortByOp,
firstDayOfWeekIdx,
}: createCustomSpreadsheetProps) {
const [categoryList, categoryGroup] = categoryLists(categories);
@@ -135,10 +137,20 @@ export function createGroupedSpreadsheet({
},
[startDate, endDate],
);
setData(
groupedData.filter(i =>
filterEmptyRows({ showEmpty, data: i, balanceTypeOp }),
),
const groupedDataFiltered = groupedData.filter(i =>
filterEmptyRows({ showEmpty, data: i, balanceTypeOp }),
);
const sortedGroupedDataFiltered = [...groupedDataFiltered]
.sort(sortData({ balanceTypeOp, sortByOp }))
.map(g => {
g.categories = [...(g.categories ?? [])].sort(
sortData({ balanceTypeOp, sortByOp }),
);
return g;
});
setData(sortedGroupedDataFiltered);
};
}

View File

@@ -0,0 +1,45 @@
import {
type balanceTypeOpType,
type sortByOpType,
type GroupedEntity,
} from 'loot-core/src/types/models/reports';
const reverseSort: Partial<Record<sortByOpType, sortByOpType>> = {
asc: 'desc',
desc: 'asc',
};
const balanceTypesToReverse = ['totalDebts', 'netDebts'];
const shouldReverse = (balanceTypeOp: balanceTypeOpType) =>
balanceTypesToReverse.includes(balanceTypeOp);
export function sortData({
balanceTypeOp,
sortByOp,
}: {
balanceTypeOp?: balanceTypeOpType;
sortByOp?: sortByOpType;
}): (a: GroupedEntity, b: GroupedEntity) => number {
if (!balanceTypeOp || !sortByOp) return () => 0;
if (shouldReverse(balanceTypeOp)) {
sortByOp = reverseSort[sortByOp] ?? sortByOp;
}
// Return a comparator function
return (a, b) => {
let comparison = 0;
if (sortByOp === 'asc') {
comparison = a[balanceTypeOp] - b[balanceTypeOp];
} else if (sortByOp === 'desc') {
comparison = b[balanceTypeOp] - a[balanceTypeOp];
} else if (sortByOp === 'name') {
comparison = (a.name ?? '').localeCompare(b.name ?? '');
} else if (sortByOp === 'budget') {
comparison = 0;
}
return comparison;
};
}

View File

@@ -0,0 +1,7 @@
BEGIN TRANSACTION;
ALTER TABLE custom_reports ADD COLUMN sort_by TEXT DEFAULT 'Descending';
UPDATE custom_reports SET sort_by = 'Descending';
UPDATE custom_reports SET sort_by = 'Budget' where graph_type = 'TableGraph';
COMMIT;

View File

@@ -18,6 +18,7 @@ function toJS(rows: CustomReportData[]) {
dateRange: row.date_range,
mode: row.mode,
groupBy: row.group_by,
sortBy: row.sort_by,
interval: row.interval,
balanceType: row.balance_type,
showEmpty: row.show_empty === 1,

View File

@@ -142,6 +142,7 @@ export const schema = {
date_range: f('string'),
mode: f('string', { default: 'total' }),
group_by: f('string', { default: 'Category' }),
sort_by: f('string', { default: 'desc' }),
balance_type: f('string', { default: 'Expense' }),
show_empty: f('integer', { default: 0 }),
show_offbudget: f('integer', { default: 0 }),

View File

@@ -41,6 +41,7 @@ export const reportModel = {
dateRange: row.date_range,
mode: row.mode,
groupBy: row.group_by,
sortBy: row.sort_by,
interval: row.interval,
balanceType: row.balance_type,
showEmpty: row.show_empty === 1,
@@ -64,6 +65,7 @@ export const reportModel = {
date_range: report.dateRange,
mode: report.mode,
group_by: report.groupBy,
sort_by: report.sortBy,
interval: report.interval,
balance_type: report.balanceType,
show_empty: report.showEmpty ? 1 : 0,
@@ -96,7 +98,7 @@ async function reportNameExists(
//for update/rename
if (!newItem) {
/*
-if the found item is the same as the existing item
-if the found item is the same as the existing item
then no name change was made.
-if they are not the same then there is another
item with that name already.

View File

@@ -11,6 +11,7 @@ export interface CustomReportEntity {
groupBy: string;
interval: string;
balanceType: string;
sortBy: string;
showEmpty: boolean;
showOffBudget: boolean;
showHiddenCategories: boolean;
@@ -30,6 +31,8 @@ export type balanceTypeOpType =
| 'netAssets'
| 'netDebts';
export type sortByOpType = 'asc' | 'desc' | 'name' | 'budget';
export type SpendingMonthEntity = Record<
string | number,
{
@@ -122,6 +125,7 @@ export interface CustomReportData {
date_range: string;
mode: string;
group_by: string;
sort_by: sortByOpType;
balance_type: string;
show_empty: number;
show_offbudget: number;

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [matt-fidd]
---
Add sorting options to custom reports