diff --git a/packages/desktop-client/e2e/transactions.test.ts b/packages/desktop-client/e2e/transactions.test.ts
index 1517e9a520..120e2f1547 100644
--- a/packages/desktop-client/e2e/transactions.test.ts
+++ b/packages/desktop-client/e2e/transactions.test.ts
@@ -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();
});
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png
index e64deb902c..4879b75a7c 100644
Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-10-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-10-chromium-linux.png
new file mode 100644
index 0000000000..0c2feecdf1
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-10-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-11-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-11-chromium-linux.png
new file mode 100644
index 0000000000..65047d5662
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-11-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-12-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-12-chromium-linux.png
new file mode 100644
index 0000000000..18011cb585
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-12-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png
index c8535a23a8..57a4880049 100644
Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png
index e59f942f33..b2ac55d12b 100644
Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png
index 69f6389f5f..83c424f46b 100644
Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-7-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png
index 490fa1a300..23f6789ebd 100644
Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-8-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png
index c7769ec55b..f16d50a89c 100644
Binary files a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-9-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-1-chromium-linux.png
new file mode 100644
index 0000000000..6cb6ea2338
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-10-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-10-chromium-linux.png
new file mode 100644
index 0000000000..c09e24b44a
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-10-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-11-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-11-chromium-linux.png
new file mode 100644
index 0000000000..d7c39fc78b
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-11-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-12-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-12-chromium-linux.png
new file mode 100644
index 0000000000..46db12c856
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-12-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-2-chromium-linux.png
new file mode 100644
index 0000000000..7b38278bf7
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-3-chromium-linux.png
new file mode 100644
index 0000000000..b8918ab6d0
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-4-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-4-chromium-linux.png
new file mode 100644
index 0000000000..20fa7eb018
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-4-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-5-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-5-chromium-linux.png
new file mode 100644
index 0000000000..76254c2986
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-5-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-6-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-6-chromium-linux.png
new file mode 100644
index 0000000000..fb3f9f4b66
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-6-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-7-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-7-chromium-linux.png
new file mode 100644
index 0000000000..2c53b5889d
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-7-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-8-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-8-chromium-linux.png
new file mode 100644
index 0000000000..5898d6bf27
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-8-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-9-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-9-chromium-linux.png
new file mode 100644
index 0000000000..0170862c02
Binary files /dev/null and b/packages/desktop-client/e2e/transactions.test.ts-snapshots/Transactions-filters-transactions-by-category-group-9-chromium-linux.png differ
diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index f73223f057..856279616f 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -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 ;
+ case 'category-group-autocomplete':
+ return (
+
+ );
+
case 'account-autocomplete':
return ;
diff --git a/packages/desktop-client/src/components/autocomplete/CategoryGroupAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/CategoryGroupAutocomplete.tsx
new file mode 100644
index 0000000000..44354a8190
--- /dev/null
+++ b/packages/desktop-client/src/components/autocomplete/CategoryGroupAutocomplete.tsx
@@ -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>;
+ highlightedIndex: number;
+ embedded?: boolean;
+ footer?: ReactNode;
+ renderCategoryGroupItem?: (
+ props: ComponentPropsWithoutRef,
+ ) => ReactElement;
+ 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 (
+
+
+ {categoryGroups.map(item => (
+
+ {renderCategoryGroupItem({
+ ...(getItemProps ? getItemProps({ item }) : {}),
+ item,
+ highlighted: highlightedIndex === item.highlightedIndex,
+ embedded,
+ style: {
+ ...(showHiddenItems &&
+ item.hidden && {
+ color: theme.pageTextSubdued,
+ }),
+ },
+ })}
+
+ ))}
+
+ {footer}
+
+ );
+}
+
+type CategoryGroupAutocompleteProps = ComponentProps<
+ typeof Autocomplete
+> & {
+ categoryGroups?: Array;
+ renderCategoryGroupItem?: (
+ props: ComponentPropsWithoutRef,
+ ) => ReactElement;
+ 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 (
+ (
+
+ )}
+ {...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 (
+
+ );
+}
+
+function defaultRenderCategoryItem(
+ props: ComponentPropsWithoutRef,
+): ReactElement {
+ return ;
+}
diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.tsx b/packages/desktop-client/src/components/filters/FiltersMenu.tsx
index 7aa1316e91..05a8337fba 100644
--- a/packages/desktop-client/src/components/filters/FiltersMenu.tsx
+++ b/packages/desktop-client/src/components/filters/FiltersMenu.tsx
@@ -82,8 +82,8 @@ type ConfigureFieldProps =
};
function ConfigureField({
- field,
- initialSubfield = field,
+ field: initialField,
+ initialSubfield = initialField,
op,
value,
dispatch,
@@ -92,9 +92,11 @@ function ConfigureField({
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(null);
const prevOp = useRef(null);
+ const prevSubfield = useRef(null);
useEffect(() => {
if (prevOp.current !== op && inputRef.current) {
@@ -103,6 +105,13 @@ function ConfigureField({
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({
const isPayeeIdOp = (op: T['op']) =>
['is', 'is not', 'one of', 'not one of'].includes(op);
+ const subfieldSelectOptions = (
+ field: 'amount' | 'date' | 'category',
+ ): Array => {
+ 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 (
- {field === 'amount' || field === 'date' ? (
+ {field === 'amount' || field === 'date' || field === 'category' ? (