mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-11 22:49:59 +00:00
Add Category Group filtering in Budget Analysis Report (#7116)
* feat: add FilterInclude to match FilterExclude * feat: enhance category filtering to support category groups in budget analysis * add release notes * fix: applied corabbit's suggestion * applied coderabbit's fixes * removed duped code * applied more coderabbit comments * [autofix.ci] apply automated fixes * [autofix.ci] apply automated fixes * fix: include docs about include parameter * fix: contains and matches now properly filter category names instead of UUID --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: youngcw <[email protected]>
This commit is contained in:
co-authored by
autofix-ci[bot]
youngcw
parent
7ce44c2e56
commit
446fde6cd9
@@ -356,18 +356,47 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the shared report filter picker.
|
||||
* Note: the `include` and `exclude` props only control which fields are shown in the picker, they do not limit the fields that can be applied as filters. Additionally, if both `include` and `exclude` are provided, `include` acts as the allowlist before `exclude` is applied.
|
||||
*/
|
||||
type FilterButtonProps<T extends RuleConditionEntity> = {
|
||||
onApply: (cond: T) => void;
|
||||
compact: boolean;
|
||||
hover: boolean;
|
||||
/** Fields hidden from the picker unless allowed by `include`. */
|
||||
exclude?: string[];
|
||||
/** If both are provided, `include` acts as the allowlist before `exclude` is applied. */
|
||||
include?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether a filter field should be shown in the picker.
|
||||
*
|
||||
* If both `include` and `exclude` are provided, `include` acts as the
|
||||
* allowlist before `exclude` is applied.
|
||||
*/
|
||||
function shouldShowFilterField(
|
||||
field: string,
|
||||
include?: string[],
|
||||
exclude?: string[],
|
||||
) {
|
||||
if (include && !include.includes(field)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return exclude ? !exclude.includes(field) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared filter picker used by reports to choose and apply filter conditions.
|
||||
*/
|
||||
export function FilterButton<T extends RuleConditionEntity>({
|
||||
onApply,
|
||||
compact,
|
||||
hover,
|
||||
exclude,
|
||||
include,
|
||||
}: FilterButtonProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const filters = useTransactionFilters();
|
||||
@@ -476,16 +505,17 @@ export function FilterButton<T extends RuleConditionEntity>({
|
||||
scopes: ['app'],
|
||||
});
|
||||
|
||||
const filterMenuItems: ComponentProps<typeof Menu>['items'] =
|
||||
translatedFilterFields
|
||||
.filter(f => (exclude ? !exclude.includes(f[0]) : true))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([name, text]) => ({
|
||||
name,
|
||||
text: titleFirst(text),
|
||||
}));
|
||||
const visibleFilterFields = translatedFilterFields
|
||||
.filter(([field]) => shouldShowFilterField(field, include, exclude))
|
||||
.sort((a, b) => a[0].localeCompare(b[0]));
|
||||
|
||||
if (!exclude?.includes('saved')) {
|
||||
const filterMenuItems: ComponentProps<typeof Menu>['items'] =
|
||||
visibleFilterFields.map(([name, text]) => ({
|
||||
name,
|
||||
text: titleFirst(text),
|
||||
}));
|
||||
|
||||
if (shouldShowFilterField('saved', include, exclude)) {
|
||||
filterMenuItems.push(Menu.line);
|
||||
filterMenuItems.push({
|
||||
name: 'saved',
|
||||
|
||||
@@ -42,6 +42,7 @@ type HeaderProps = {
|
||||
inlineContent?: ReactNode;
|
||||
// no separate category filter; use main filters instead
|
||||
filterExclude?: string[];
|
||||
filterInclude?: string[];
|
||||
} & (
|
||||
| {
|
||||
filters: RuleConditionEntity[];
|
||||
@@ -82,6 +83,7 @@ export function Header({
|
||||
children,
|
||||
inlineContent,
|
||||
filterExclude,
|
||||
filterInclude,
|
||||
}: HeaderProps) {
|
||||
const locale = useLocale();
|
||||
const { t } = useTranslation();
|
||||
@@ -295,6 +297,7 @@ export function Header({
|
||||
onApply={onApply}
|
||||
hover={false}
|
||||
exclude={filterExclude}
|
||||
include={filterInclude}
|
||||
/>
|
||||
)}
|
||||
{inlineContent}
|
||||
|
||||
@@ -317,16 +317,7 @@ function BudgetAnalysisInternal({ widget }: BudgetAnalysisInternalProps) {
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onDeleteFilter={onDeleteFilter}
|
||||
onConditionsOpChange={onConditionsOpChange}
|
||||
filterExclude={[
|
||||
'date',
|
||||
'account',
|
||||
'payee',
|
||||
'notes',
|
||||
'amount',
|
||||
'cleared',
|
||||
'reconciled',
|
||||
'transfer',
|
||||
]}
|
||||
filterInclude={['category', 'saved']}
|
||||
inlineContent={
|
||||
<Tooltip
|
||||
content={
|
||||
|
||||
+58
-12
@@ -46,11 +46,22 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
setData: (data: BudgetAnalysisData) => void,
|
||||
) => {
|
||||
// Get all categories
|
||||
const { list: allCategories } = await send('get-categories');
|
||||
const { list: allCategories, grouped: allCategoryGroups } =
|
||||
await send('get-categories');
|
||||
|
||||
// Filter categories based on conditions
|
||||
const categoryConditions = conditions.filter(
|
||||
cond => !cond.customName && cond.field === 'category',
|
||||
// Build a UUID → name map for category groups so text-based operators
|
||||
// (contains, doesNotContain, matches) can match against the group name.
|
||||
const groupNameById = new Map<string, string>(
|
||||
allCategoryGroups.map(
|
||||
(g: { id: string; name: string }) => [g.id, g.name] as const,
|
||||
),
|
||||
);
|
||||
|
||||
// Filter categories based on conditions (supports both 'category' and 'category_group' fields)
|
||||
const relevantConditions = conditions.filter(
|
||||
cond =>
|
||||
!cond.customName &&
|
||||
(cond.field === 'category' || cond.field === 'category_group'),
|
||||
);
|
||||
|
||||
// Base set: expense categories only (exclude income and hidden)
|
||||
@@ -59,18 +70,53 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
);
|
||||
|
||||
let categoriesToInclude: CategoryEntity[];
|
||||
if (categoryConditions.length > 0) {
|
||||
// Evaluate each condition to get sets of matching categories
|
||||
const conditionResults = categoryConditions.map(cond => {
|
||||
if (relevantConditions.length > 0) {
|
||||
// Evaluate each condition to get sets of matching categories.
|
||||
// category_group conditions are expanded to their member categories via cat.group.
|
||||
const conditionResults = relevantConditions.map(cond => {
|
||||
const getKey = (cat: CategoryEntity) =>
|
||||
cond.field === 'category_group' ? cat.group : cat.id;
|
||||
const matchesRegex =
|
||||
cond.op === 'matches' &&
|
||||
typeof cond.value === 'string' &&
|
||||
cond.value.length <= 256
|
||||
? (() => {
|
||||
try {
|
||||
return new RegExp(cond.value, 'i');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
return baseCategories.filter((cat: CategoryEntity) => {
|
||||
const key = getKey(cat);
|
||||
// For text-based operators, compare against the human-readable name
|
||||
// rather than the UUID. For category_group, resolve UUID → name via
|
||||
// the map; for category, use the category's own name directly.
|
||||
const textValue =
|
||||
cond.field === 'category_group'
|
||||
? (groupNameById.get(key) ?? key)
|
||||
: cat.name;
|
||||
if (cond.op === 'is') {
|
||||
return cond.value === cat.id;
|
||||
return cond.value === key;
|
||||
} else if (cond.op === 'isNot') {
|
||||
return cond.value !== cat.id;
|
||||
return cond.value !== key;
|
||||
} else if (cond.op === 'oneOf') {
|
||||
return cond.value.includes(cat.id);
|
||||
return Array.isArray(cond.value) && cond.value.includes(key);
|
||||
} else if (cond.op === 'notOneOf') {
|
||||
return !cond.value.includes(cat.id);
|
||||
return Array.isArray(cond.value) && !cond.value.includes(key);
|
||||
} else if (cond.op === 'contains') {
|
||||
return (
|
||||
typeof cond.value === 'string' &&
|
||||
textValue.toLowerCase().includes(cond.value.toLowerCase())
|
||||
);
|
||||
} else if (cond.op === 'doesNotContain') {
|
||||
return (
|
||||
typeof cond.value === 'string' &&
|
||||
!textValue.toLowerCase().includes(cond.value.toLowerCase())
|
||||
);
|
||||
} else if (cond.op === 'matches') {
|
||||
return matchesRegex?.test(textValue) ?? false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
@@ -104,7 +150,7 @@ export function createBudgetAnalysisSpreadsheet({
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No category filter, use all expense categories
|
||||
// No category or category group filter — include all expense categories
|
||||
categoriesToInclude = baseCategories;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [tabedzki]
|
||||
---
|
||||
|
||||
Add Category Group support to Budget Analysis Report
|
||||
Reference in New Issue
Block a user