Add filter option for category groups (#6834)

* Add filter by category groups

* Add tests

* Add release notes

* [autofix.ci] apply automated fixes

* Fix typecheck findings

* Fix modal

* Address nitpick comment (filterBy)

* Fix e2e tests

* Make group a subfield of category

* Fix test by typing in autocomplete

* Replace testId with a11y lookups

* Apply new type import style rules

* Apply feedback

* Improve typing on array reduce, remove manual type coercion

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Roy
2026-02-14 16:03:11 +01:00
committed by GitHub
parent 7e8a118411
commit 5943ae3df5
36 changed files with 552 additions and 52 deletions

View File

@@ -62,26 +62,56 @@ test.describe('Transactions', () => {
const autocomplete = page.getByTestId('autocomplete');
await expect(autocomplete).toMatchThemeScreenshots();
// Ensure that autocomplete filters properly
await page.keyboard.type('C');
await expect(autocomplete).toMatchThemeScreenshots();
// Select the active item
await page.getByTestId('Clothing-category-item').click();
await filterTooltip.applyButton.click();
// Assert that there are only clothing transactions
await expect(accountPage.getNthTransaction(0).category).toHaveText(
'Clothing',
);
await expect(accountPage.getNthTransaction(1).category).toHaveText(
'Clothing',
);
await expect(accountPage.getNthTransaction(2).category).toHaveText(
'Clothing',
);
await expect(accountPage.getNthTransaction(3).category).toHaveText(
'Clothing',
);
await expect(accountPage.getNthTransaction(4).category).toHaveText(
'Clothing',
);
for (let i = 0; i < 5; i++) {
await expect(accountPage.getNthTransaction(i).category).toHaveText(
'Clothing',
);
}
await expect(page).toMatchThemeScreenshots();
});
test('by category group', async () => {
// Use Capital One Checking because it has transactions that aren't just Clothing
accountPage = await navigation.goToAccountPage('Capital One Checking');
const filterTooltip = await accountPage.filterBy('Category');
await filterTooltip.locator
.getByRole('button', { name: 'Category', exact: true })
.click();
await page
.getByRole('button', { name: 'Category group', exact: true })
.click();
await expect(filterTooltip.locator).toMatchThemeScreenshots();
// Type in the autocomplete box
const autocomplete = page.getByTestId('autocomplete');
await expect(autocomplete).toMatchThemeScreenshots();
// Ensure that autocomplete filters properly
await page.keyboard.type('U');
await expect(autocomplete).toMatchThemeScreenshots();
// Select the active item
await page.getByTestId('Usual Expenses-category-group-item').click();
await filterTooltip.applyButton.click();
// Assert that there are only transactions with categories in the Usual Expenses group
for (let i = 0; i < 5; i++) {
await expect(accountPage.getNthTransaction(i).category).toHaveText(
/^(Savings|Medical|Gift|General|Clothing|Entertainment|Restaurants|Food)$/,
);
}
await expect(page).toMatchThemeScreenshots();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

View File

@@ -11,6 +11,7 @@ import { AccountMenuModal } from './modals/AccountMenuModal';
import { BudgetAutomationsModal } from './modals/BudgetAutomationsModal';
import { BudgetPageMenuModal } from './modals/BudgetPageMenuModal';
import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal';
import { CategoryGroupAutocompleteModal } from './modals/CategoryGroupAutocompleteModal';
import { CategoryGroupMenuModal } from './modals/CategoryGroupMenuModal';
import { CategoryMenuModal } from './modals/CategoryMenuModal';
import { CloseAccountModal } from './modals/CloseAccountModal';
@@ -206,6 +207,11 @@ export function Modals() {
case 'category-autocomplete':
return <CategoryAutocompleteModal key={key} {...modal.options} />;
case 'category-group-autocomplete':
return (
<CategoryGroupAutocompleteModal key={key} {...modal.options} />
);
case 'account-autocomplete':
return <AccountAutocompleteModal key={key} {...modal.options} />;

View File

@@ -0,0 +1,219 @@
import React, { Fragment, useMemo } from 'react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
CSSProperties,
ReactElement,
ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { styles } from '@actual-app/components/styles';
import { TextOneLine } from '@actual-app/components/text-one-line';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import type { CategoryGroupEntity } from 'loot-core/types/models';
import { Autocomplete } from './Autocomplete';
import { useCategories } from '@desktop-client/hooks/useCategories';
type CategoryGroupAutocompleteItem = CategoryGroupEntity;
type CategoryGroupListProps = {
items: CategoryGroupAutocompleteItem[];
getItemProps?: (arg: {
item: CategoryGroupAutocompleteItem;
}) => Partial<ComponentProps<typeof View>>;
highlightedIndex: number;
embedded?: boolean;
footer?: ReactNode;
renderCategoryGroupItem?: (
props: ComponentPropsWithoutRef<typeof CategoryGroupItem>,
) => ReactElement<typeof CategoryGroupItem>;
showHiddenItems?: boolean;
};
function CategoryGroupList({
items,
getItemProps,
highlightedIndex,
embedded,
footer,
renderCategoryGroupItem = defaultRenderCategoryItem,
showHiddenItems,
}: CategoryGroupListProps) {
const categoryGroups = useMemo(() => {
return items.reduce<
(CategoryGroupAutocompleteItem & { highlightedIndex: number })[]
>((acc, item, index) => {
const itemWithIndex = {
...item,
highlightedIndex: index,
};
acc.push(itemWithIndex);
return acc;
}, []);
}, [items]);
return (
<View>
<View
style={{
overflowY: 'auto',
willChange: 'transform',
padding: '5px 0',
...(!embedded && { maxHeight: 175 }),
}}
>
{categoryGroups.map(item => (
<Fragment key={item.id}>
{renderCategoryGroupItem({
...(getItemProps ? getItemProps({ item }) : {}),
item,
highlighted: highlightedIndex === item.highlightedIndex,
embedded,
style: {
...(showHiddenItems &&
item.hidden && {
color: theme.pageTextSubdued,
}),
},
})}
</Fragment>
))}
</View>
{footer}
</View>
);
}
type CategoryGroupAutocompleteProps = ComponentProps<
typeof Autocomplete<CategoryGroupAutocompleteItem>
> & {
categoryGroups?: Array<CategoryGroupEntity>;
renderCategoryGroupItem?: (
props: ComponentPropsWithoutRef<typeof CategoryGroupItem>,
) => ReactElement<typeof CategoryGroupItem>;
showHiddenCategories?: boolean;
};
export function CategoryGroupAutocomplete({
categoryGroups,
embedded,
closeOnBlur,
renderCategoryGroupItem,
showHiddenCategories,
...props
}: CategoryGroupAutocompleteProps) {
const {
data: { grouped: defaultCategoryGroups } = {
grouped: [],
},
} = useCategories();
const categoryGroupSuggestions: CategoryGroupAutocompleteItem[] =
useMemo(() => {
const allSuggestions = categoryGroups || defaultCategoryGroups;
if (!showHiddenCategories) {
return allSuggestions.filter(suggestion => !suggestion.hidden);
}
return allSuggestions;
}, [categoryGroups, defaultCategoryGroups, showHiddenCategories]);
return (
<Autocomplete
strict
highlightFirst
embedded={embedded}
closeOnBlur={closeOnBlur}
suggestions={categoryGroupSuggestions}
renderItems={(items, getItemProps, highlightedIndex) => (
<CategoryGroupList
items={items}
embedded={embedded}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
renderCategoryGroupItem={renderCategoryGroupItem}
showHiddenItems={showHiddenCategories}
/>
)}
{...props}
/>
);
}
type CategoryGroupItemProps = {
item: CategoryGroupAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
embedded?: boolean;
showBalances?: boolean;
};
function CategoryGroupItem({
item,
className,
style,
highlighted,
embedded,
...props
}: CategoryGroupItemProps) {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<button
type="button"
style={style}
className={cx(
className,
css({
backgroundColor: highlighted
? theme.menuAutoCompleteBackgroundHover
: 'transparent',
color: highlighted
? theme.menuAutoCompleteItemTextHover
: theme.menuAutoCompleteItemText,
padding: 4,
paddingLeft: 20,
borderRadius: embedded ? 4 : 0,
border: 'none',
font: 'inherit',
...narrowStyle,
}),
)}
data-testid={`${item.name}-category-group-item`}
data-highlighted={highlighted || undefined}
{...props}
>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<TextOneLine>
{item.name}
{item.hidden ? ' ' + t('(hidden)') : ''}
</TextOneLine>
</View>
</button>
);
}
function defaultRenderCategoryItem(
props: ComponentPropsWithoutRef<typeof CategoryGroupItem>,
): ReactElement<typeof CategoryGroupItem> {
return <CategoryGroupItem {...props} />;
}

View File

@@ -82,8 +82,8 @@ type ConfigureFieldProps<T extends RuleConditionEntity> =
};
function ConfigureField<T extends RuleConditionEntity>({
field,
initialSubfield = field,
field: initialField,
initialSubfield = initialField,
op,
value,
dispatch,
@@ -92,9 +92,11 @@ function ConfigureField<T extends RuleConditionEntity>({
const { t } = useTranslation();
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const field = initialField === 'category_group' ? 'category' : initialField;
const [subfield, setSubfield] = useState(initialSubfield);
const inputRef = useRef<AmountInputRef>(null);
const prevOp = useRef<T['op'] | null>(null);
const prevSubfield = useRef<string | null>(null);
useEffect(() => {
if (prevOp.current !== op && inputRef.current) {
@@ -103,6 +105,13 @@ function ConfigureField<T extends RuleConditionEntity>({
prevOp.current = op;
}, [op]);
useEffect(() => {
if (prevSubfield.current !== subfield && inputRef.current) {
inputRef.current.focus();
}
prevSubfield.current = subfield;
}, [subfield]);
const type = FIELD_TYPES.get(field);
let ops = getValidOps(field).filter(op => op !== 'isbetween');
@@ -131,25 +140,42 @@ function ConfigureField<T extends RuleConditionEntity>({
const isPayeeIdOp = (op: T['op']) =>
['is', 'is not', 'one of', 'not one of'].includes(op);
const subfieldSelectOptions = (
field: 'amount' | 'date' | 'category',
): Array<readonly [string, string]> => {
switch (field) {
case 'amount':
return [
['amount', t('Amount')],
['amount-inflow', t('Amount (inflow)')],
['amount-outflow', t('Amount (outflow)')],
];
case 'date':
return [
['date', t('Date')],
['month', t('Month')],
['year', t('Year')],
];
case 'category':
return [
['category', t('Category')],
['category_group', t('Category group')],
];
default:
return [];
}
};
return (
<FocusScope>
<View style={{ marginBottom: 10 }}>
<SpaceBetween style={{ alignItems: 'flex-start' }}>
{field === 'amount' || field === 'date' ? (
{field === 'amount' || field === 'date' || field === 'category' ? (
<Select
options={
field === 'amount'
? [
['amount', t('Amount')],
['amount-inflow', t('Amount (inflow)')],
['amount-outflow', t('Amount (outflow)')],
]
: [
['date', t('Date')],
['month', t('Month')],
['year', t('Year')],
]
}
options={subfieldSelectOptions(field)}
value={subfield}
onChange={sub => {
setSubfield(sub);
@@ -235,6 +261,7 @@ function ConfigureField<T extends RuleConditionEntity>({
e.preventDefault();
let submitValue = value;
let storableField = field;
if (field === 'amount' && inputRef.current) {
try {
@@ -256,9 +283,13 @@ function ConfigureField<T extends RuleConditionEntity>({
}
}
if (field === 'category') {
storableField = subfield;
}
// @ts-expect-error - fix me
onApply({
field,
field: storableField,
op,
value: submitValue,
options: subfieldToOptions(field, subfield),
@@ -269,7 +300,7 @@ function ConfigureField<T extends RuleConditionEntity>({
<GenericInput
ref={inputRef}
// @ts-expect-error - fix me
field={field === 'date' ? subfield : field}
field={field === 'date' || field === 'category' ? subfield : field}
// @ts-expect-error - fix me
type={
type === 'id' &&

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import * as monthUtils from 'loot-core/shared/months';
import { CategoryGroupAutocomplete } from '@desktop-client/components/autocomplete/CategoryGroupAutocomplete';
import {
Modal,
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { SectionLabel } from '@desktop-client/components/forms';
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type CategoryGroupAutocompleteModalProps = Extract<
ModalType,
{ name: 'category-group-autocomplete' }
>['options'];
export function CategoryGroupAutocompleteModal({
title,
month,
onSelect,
categoryGroups,
showHiddenCategories,
closeOnSelect,
clearOnSelect,
onClose,
}: CategoryGroupAutocompleteModalProps) {
const { t } = useTranslation();
const { isNarrowWidth } = useResponsive();
const defaultAutocompleteProps = {
containerProps: { style: { height: isNarrowWidth ? '90vh' : 275 } },
};
return (
<Modal
name="category-group-autocomplete"
noAnimation={!isNarrowWidth}
onClose={onClose}
containerProps={{
style: {
height: isNarrowWidth
? 'calc(var(--visual-viewport-height) * 0.85)'
: 275,
backgroundColor: theme.menuAutoCompleteBackground,
},
}}
>
{({ state: { close } }) => (
<>
{isNarrowWidth && (
<ModalHeader
title={
<ModalTitle
title={title || t('Category group')}
getStyle={() => ({ color: theme.menuAutoCompleteText })}
/>
}
rightContent={
<ModalCloseButton
onPress={close}
style={{ color: theme.menuAutoCompleteText }}
/>
}
/>
)}
<View>
{!isNarrowWidth && (
<SectionLabel
title={t('Category group')}
style={{
alignSelf: 'center',
color: theme.menuAutoCompleteText,
marginBottom: 10,
}}
/>
)}
<View style={{ flex: 1 }}>
<SheetNameProvider
name={month ? monthUtils.sheetForMonth(month) : ''}
>
<CategoryGroupAutocomplete
focused
embedded
closeOnBlur={false}
closeOnSelect={closeOnSelect}
clearOnSelect={clearOnSelect}
onClose={close}
{...defaultAutocompleteProps}
onSelect={onSelect}
categoryGroups={categoryGroups}
showHiddenCategories={showHiddenCategories}
value={null}
/>
</SheetNameProvider>
</View>
</View>
</>
)}
</Modal>
);
}

View File

@@ -185,13 +185,16 @@ function CustomReportInner({ report: initialReport }: CustomReportInnerProps) {
// Complex category conditions are:
// - conditions with multiple "category" fields
// - conditions with "category" field that use "contains", "doesNotContain" or "matches" operations
// - conditions with "category" field that use "contains", "doesNotContain", "matches", "hasTags" operations
// - conditions with a "category_group" field
const isComplexCategoryCondition =
!!conditions.find(
({ field, op }) =>
field === 'category' &&
['contains', 'doesNotContain', 'matches', 'hasTags'].includes(op),
) || conditions.filter(({ field }) => field === 'category').length >= 2;
) ||
conditions.filter(({ field }) => field === 'category').length >= 2 ||
conditions.filter(({ field }) => field === 'category_group').length >= 1;
const setSelectedCategories = (newCategories: CategoryEntity[]) => {
const newCategoryIdSet = new Set(newCategories.map(({ id }) => id));

View File

@@ -968,6 +968,7 @@ const conditionFields = [
'imported_payee',
'account',
'category',
'category_group',
'date',
'payee',
'notes',

View File

@@ -43,7 +43,12 @@ export function Value<T>({
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const payees = usePayees();
const { data: { list: categories } = { list: [] } } = useCategories();
const {
data: { list: categories, grouped: categoryGroups } = {
list: [],
grouped: [],
},
} = useCategories();
const accounts = useAccounts();
const valueStyle = {
color: theme.pageTextPositive,
@@ -52,15 +57,30 @@ export function Value<T>({
const ValueText = field === 'amount' ? FinancialText : Text;
const locale = useLocale();
const data =
dataProp ||
(field === 'payee'
? payees
: field === 'category'
? categories
: field === 'account'
? accounts
: []);
function getData() {
if (dataProp) {
return dataProp;
}
switch (field) {
case 'payee':
return payees;
case 'category':
return categories;
case 'category_group':
return categoryGroups;
case 'account':
return accounts;
default:
return [];
}
}
const data = getData();
const [expanded, setExpanded] = useState(false);
@@ -77,6 +97,8 @@ export function Value<T>({
} else {
switch (field) {
case 'amount':
case 'amount-inflow':
case 'amount-outflow':
return format(value, 'financial');
case 'date':
if (value) {
@@ -98,6 +120,7 @@ export function Value<T>({
return value;
case 'payee':
case 'category':
case 'category_group':
case 'account':
case 'rule':
if (valueIsRaw) {

View File

@@ -15,6 +15,7 @@ import { PercentInput } from './PercentInput';
import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete';
import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete';
import { CategoryAutocomplete } from '@desktop-client/components/autocomplete/CategoryAutocomplete';
import { CategoryGroupAutocomplete } from '@desktop-client/components/autocomplete/CategoryGroupAutocomplete';
import { FilterAutocomplete } from '@desktop-client/components/autocomplete/FilterAutocomplete';
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
import { ReportAutocomplete } from '@desktop-client/components/autocomplete/ReportAutocomplete';
@@ -34,7 +35,7 @@ type GenericInputProps = {
| ((
| {
type: 'id';
field: 'payee' | 'category';
field: 'payee' | 'category' | 'category_group';
}
| {
type: 'id';
@@ -264,6 +265,44 @@ export const GenericInput = ({
);
break;
case 'category_group':
content = (
<CategoryGroupAutocomplete
{...multiProps}
categoryGroups={categoryGroups}
openOnFocus={!isNarrowWidth}
updateOnValueChange={isNarrowWidth}
showHiddenCategories
inputProps={{
ref,
...(showPlaceholder ? { placeholder: t('nothing') } : null),
onClick: () => {
if (!isNarrowWidth) {
return;
}
dispatch(
pushModal({
modal: {
name: 'category-group-autocomplete',
options: {
onSelect: newValue => {
if (props.multi === true) {
props.onChange([...props.value, newValue]);
return;
}
props.onChange(newValue);
},
},
},
}),
);
},
}}
/>
);
break;
default:
}
break;

View File

@@ -231,6 +231,19 @@ export type Modal =
onClose?: () => void;
};
}
| {
name: 'category-group-autocomplete';
options: {
title?: string;
categoryGroups?: CategoryGroupEntity[];
onSelect: (categoryGroupId: string, categoryGroupName: string) => void;
month?: string | undefined;
showHiddenCategories?: boolean;
closeOnSelect?: boolean;
clearOnSelect?: boolean;
onClose?: () => void;
};
}
| {
name: 'account-autocomplete';
options: {