diff --git a/packages/desktop-client/e2e/page-models/mobile-navigation.ts b/packages/desktop-client/e2e/page-models/mobile-navigation.ts
index 411767c37f..9edf5ae420 100644
--- a/packages/desktop-client/e2e/page-models/mobile-navigation.ts
+++ b/packages/desktop-client/e2e/page-models/mobile-navigation.ts
@@ -4,6 +4,7 @@ import { MobileAccountPage } from './mobile-account-page';
import { MobileAccountsPage } from './mobile-accounts-page';
import { MobileBudgetPage } from './mobile-budget-page';
import { MobileReportsPage } from './mobile-reports-page';
+import { MobileRulesPage } from './mobile-rules-page';
import { MobileTransactionEntryPage } from './mobile-transaction-entry-page';
import { SettingsPage } from './settings-page';
@@ -20,6 +21,7 @@ const ROUTES_BY_PAGE = {
Accounts: '/accounts',
Transaction: '/transactions/new',
Reports: '/reports',
+ Rules: '/rules',
Settings: '/settings',
};
@@ -164,6 +166,13 @@ export class MobileNavigation {
);
}
+ async goToRulesPage() {
+ return await this.navigateToPage(
+ 'Rules',
+ () => new MobileRulesPage(this.page),
+ );
+ }
+
async goToSettingsPage() {
return await this.navigateToPage(
'Settings',
diff --git a/packages/desktop-client/e2e/page-models/mobile-rules-page.ts b/packages/desktop-client/e2e/page-models/mobile-rules-page.ts
new file mode 100644
index 0000000000..d626dbe6f1
--- /dev/null
+++ b/packages/desktop-client/e2e/page-models/mobile-rules-page.ts
@@ -0,0 +1,118 @@
+import { type Locator, type Page } from '@playwright/test';
+
+export class MobileRulesPage {
+ readonly page: Page;
+ readonly searchBox: Locator;
+ readonly addButton: Locator;
+ readonly rulesList: Locator;
+ readonly emptyMessage: Locator;
+
+ constructor(page: Page) {
+ this.page = page;
+ this.searchBox = page.getByPlaceholder('Filter rules…');
+ this.addButton = page.getByRole('button', { name: 'Add new rule' });
+ this.rulesList = page.getByRole('main');
+ this.emptyMessage = page.getByText('No rules found');
+ }
+
+ async waitFor(options?: {
+ state?: 'attached' | 'detached' | 'visible' | 'hidden';
+ timeout?: number;
+ }) {
+ await this.rulesList.waitFor(options);
+ }
+
+ /**
+ * Search for rules using the search box
+ */
+ async searchFor(text: string) {
+ await this.searchBox.fill(text);
+ }
+
+ /**
+ * Clear the search box
+ */
+ async clearSearch() {
+ await this.searchBox.fill('');
+ }
+
+ /**
+ * Get the nth rule item (0-based index)
+ */
+ getNthRule(index: number) {
+ return this.page
+ .getByRole('button')
+ .filter({ hasText: /IF|THEN/ })
+ .nth(index);
+ }
+
+ /**
+ * Get all visible rule items
+ */
+ getAllRules() {
+ return this.page.getByRole('button').filter({ hasText: /IF|THEN/ });
+ }
+
+ /**
+ * Click on a rule to edit it
+ */
+ async clickRule(index: number) {
+ const rule = this.getNthRule(index);
+ await rule.click();
+ }
+
+ /**
+ * Click the add button to create a new rule
+ */
+ async clickAddRule() {
+ await this.addButton.click();
+ }
+
+ /**
+ * Get the number of visible rules
+ */
+ async getRuleCount() {
+ const rules = this.getAllRules();
+ return await rules.count();
+ }
+
+ /**
+ * Check if the search bar has a border
+ */
+ async hasSearchBarBorder() {
+ const searchContainer = this.searchBox.locator('..');
+ const borderStyle = await searchContainer.evaluate(el => {
+ const style = window.getComputedStyle(el);
+ return style.borderBottomWidth;
+ });
+ return borderStyle === '2px';
+ }
+
+ /**
+ * Get the background color of the search box
+ */
+ async getSearchBackgroundColor() {
+ return await this.searchBox.evaluate(el => {
+ const style = window.getComputedStyle(el);
+ return style.backgroundColor;
+ });
+ }
+
+ /**
+ * Check if a rule contains specific text
+ */
+ async ruleContainsText(index: number, text: string) {
+ const rule = this.getNthRule(index);
+ const ruleText = await rule.textContent();
+ return ruleText?.includes(text) || false;
+ }
+
+ /**
+ * Get the stage badge text for a rule (PRE/DEFAULT/POST)
+ */
+ async getRuleStage(index: number) {
+ const rule = this.getNthRule(index);
+ const stageBadge = rule.locator('span').first();
+ return await stageBadge.textContent();
+ }
+}
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts b/packages/desktop-client/e2e/rules.mobile.test.ts
new file mode 100644
index 0000000000..a143b5f521
--- /dev/null
+++ b/packages/desktop-client/e2e/rules.mobile.test.ts
@@ -0,0 +1,90 @@
+import { type Page } from '@playwright/test';
+
+import { expect, test } from './fixtures';
+import { ConfigurationPage } from './page-models/configuration-page';
+import { MobileNavigation } from './page-models/mobile-navigation';
+import { type MobileRulesPage } from './page-models/mobile-rules-page';
+
+test.describe('Mobile Rules', () => {
+ let page: Page;
+ let navigation: MobileNavigation;
+ let rulesPage: MobileRulesPage;
+ let configurationPage: ConfigurationPage;
+
+ test.beforeEach(async ({ browser }) => {
+ page = await browser.newPage();
+ navigation = new MobileNavigation(page);
+ configurationPage = new ConfigurationPage(page);
+
+ // Set mobile viewport
+ await page.setViewportSize({
+ width: 350,
+ height: 600,
+ });
+
+ await page.goto('/');
+ await configurationPage.createTestFile();
+
+ // Navigate to rules page and wait for it to load
+ rulesPage = await navigation.goToRulesPage();
+ });
+
+ test.afterEach(async () => {
+ await page.close();
+ });
+
+ test('checks the page visuals', async () => {
+ await rulesPage.searchFor('Dominion');
+
+ // Check that the header is present
+ await expect(page.getByRole('heading', { name: 'Rules' })).toBeVisible();
+
+ // Check that the add button is present
+ await expect(rulesPage.addButton).toBeVisible();
+
+ // Check that the search box is present with proper placeholder
+ await expect(rulesPage.searchBox).toBeVisible();
+ await expect(rulesPage.searchBox).toHaveAttribute(
+ 'placeholder',
+ 'Filter rules…',
+ );
+
+ await expect(page).toMatchThemeScreenshots();
+ });
+
+ test('clicking add button opens rule creation modal', async () => {
+ await rulesPage.clickAddRule();
+
+ // Check that edit rule modal is opened
+ const modal = page.getByRole('dialog');
+ await expect(modal).toBeVisible();
+ await expect(page).toMatchThemeScreenshots();
+ });
+
+ test('clicking on a rule opens edit modal', async () => {
+ const ruleCount = await rulesPage.getRuleCount();
+ expect(ruleCount).toBeGreaterThan(0);
+
+ await rulesPage.clickRule(0);
+
+ // Check that edit rule modal is opened
+ const modal = page.getByRole('dialog');
+ await expect(modal).toBeVisible();
+ await expect(page).toMatchThemeScreenshots();
+ });
+
+ test('page handles empty state gracefully', async () => {
+ // Search for something that won't match to get empty state
+ await rulesPage.searchFor('NonExistentRule123456789');
+ await page.waitForTimeout(500);
+
+ // Check that empty message is shown
+ const emptyMessage = page.getByText(/No rules found/);
+ await expect(emptyMessage).toBeVisible();
+
+ // Check that no rule items are visible
+ const rules = rulesPage.getAllRules();
+ await expect(rules).toHaveCount(0);
+ await expect(page).toMatchThemeScreenshots();
+ });
+});
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-1-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-1-chromium-linux.png
new file mode 100644
index 0000000000..7c48ff6f23
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-2-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-2-chromium-linux.png
new file mode 100644
index 0000000000..856fa79a8a
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-3-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-3-chromium-linux.png
new file mode 100644
index 0000000000..d8dfb41984
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-checks-the-page-visuals-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-1-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-1-chromium-linux.png
new file mode 100644
index 0000000000..ef83abe282
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-2-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-2-chromium-linux.png
new file mode 100644
index 0000000000..b94658273b
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-3-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-3-chromium-linux.png
new file mode 100644
index 0000000000..a98d8fe681
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-add-button-opens-rule-creation-modal-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-1-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-1-chromium-linux.png
new file mode 100644
index 0000000000..9539c7484e
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-2-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-2-chromium-linux.png
new file mode 100644
index 0000000000..4523e7f0e1
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-3-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-3-chromium-linux.png
new file mode 100644
index 0000000000..ca7cbf073d
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-clicking-on-a-rule-opens-edit-modal-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-1-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-1-chromium-linux.png
new file mode 100644
index 0000000000..423ff8ac34
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-2-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-2-chromium-linux.png
new file mode 100644
index 0000000000..c6468e0c71
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-3-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-3-chromium-linux.png
new file mode 100644
index 0000000000..2bf808e601
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-displays-the-rules-page-with-proper-header-and-search-bar-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-1-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-1-chromium-linux.png
new file mode 100644
index 0000000000..e2ff17a1da
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-2-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-2-chromium-linux.png
new file mode 100644
index 0000000000..666e0ed48c
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-3-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-3-chromium-linux.png
new file mode 100644
index 0000000000..d26b699f61
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-page-handles-empty-state-gracefully-3-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-1-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-1-chromium-linux.png
new file mode 100644
index 0000000000..4cead78d12
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-1-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-2-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-2-chromium-linux.png
new file mode 100644
index 0000000000..0da37a23e0
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-2-chromium-linux.png differ
diff --git a/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-3-chromium-linux.png b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-3-chromium-linux.png
new file mode 100644
index 0000000000..3985fe712a
Binary files /dev/null and b/packages/desktop-client/e2e/rules.mobile.test.ts-snapshots/Mobile-Rules-search-functionality-filters-rules-correctly-3-chromium-linux.png differ
diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx
index d0a382446a..5e50c10e31 100644
--- a/packages/desktop-client/src/components/FinancesApp.tsx
+++ b/packages/desktop-client/src/components/FinancesApp.tsx
@@ -14,7 +14,6 @@ import { BankSync } from './banksync';
import { BankSyncStatus } from './BankSyncStatus';
import { CommandBar } from './CommandBar';
import { GlobalKeys } from './GlobalKeys';
-import { ManageRulesPage } from './ManageRulesPage';
import { Category } from './mobile/budget/Category';
import { MobileNavTabs } from './mobile/MobileNavTabs';
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
@@ -257,7 +256,10 @@ export function FinancesApp() {
/>
} />
- } />
+ }
+ />
} />
} />
} />
@@ -331,6 +333,7 @@ export function FinancesApp() {
} />
} />
} />
+ } />
diff --git a/packages/desktop-client/src/components/ManageRules.tsx b/packages/desktop-client/src/components/ManageRules.tsx
index 21d2462eaa..e0d6f7548b 100644
--- a/packages/desktop-client/src/components/ManageRules.tsx
+++ b/packages/desktop-client/src/components/ManageRules.tsx
@@ -41,10 +41,22 @@ import { pushModal } from '@desktop-client/modals/modalsSlice';
import { initiallyLoadPayees } from '@desktop-client/queries/queriesSlice';
import { useDispatch } from '@desktop-client/redux';
-function mapValue(
- field,
- value,
- { payees = [], categories = [], accounts = [] },
+export type FilterData = {
+ payees?: Array<{ id: string; name: string }>;
+ categories?: Array<{ id: string; name: string }>;
+ accounts?: Array<{ id: string; name: string }>;
+ schedules?: readonly {
+ id: string;
+ rule: string;
+ _payee: string;
+ completed: boolean;
+ }[];
+};
+
+export function mapValue(
+ field: string,
+ value: unknown,
+ { payees = [], categories = [], accounts = [] }: Partial,
) {
if (!value) return '';
@@ -64,12 +76,14 @@ function mapValue(
return '(deleted)';
}
-function ruleToString(rule, data) {
+export function ruleToString(rule: RuleEntity, data: FilterData) {
const conditions = rule.conditions.flatMap(cond => [
mapField(cond.field),
friendlyOp(cond.op),
cond.op === 'oneOf' || cond.op === 'notOneOf'
- ? cond.value.map(v => mapValue(cond.field, v, data)).join(', ')
+ ? Array.isArray(cond.value)
+ ? cond.value.map(v => mapValue(cond.field, v, data)).join(', ')
+ : mapValue(cond.field, cond.value, data)
: mapValue(cond.field, cond.value, data),
]);
const actions = rule.actions.flatMap(action => {
@@ -81,19 +95,17 @@ function ruleToString(rule, data) {
mapValue(action.field, action.value, data),
];
} else if (action.op === 'link-schedule') {
- const schedule = data.schedules.find(s => s.id === action.value);
+ const schedule = data.schedules?.find(s => s.id === String(action.value));
return [
friendlyOp(action.op),
describeSchedule(
schedule,
- data.payees.find(p => p.id === schedule._payee),
+ data.payees?.find(p => p.id === schedule?._payee),
),
];
} else if (action.op === 'prepend-notes' || action.op === 'append-notes') {
- return [
- friendlyOp(action.op),
- '“' + mapValue(action.field, action.value, data) + '”',
- ];
+ const noteValue = String(action.value || '');
+ return [friendlyOp(action.op), '\u201c' + noteValue + '\u201d'];
} else {
return [];
}
diff --git a/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx b/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx
index fc680e53a9..c6a69ca3a7 100644
--- a/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx
+++ b/packages/desktop-client/src/components/mobile/MobileNavTabs.tsx
@@ -129,8 +129,8 @@ export function MobileNavTabs() {
Icon: SvgStoreFront,
},
{
- name: t('Rules (Soon)'),
- path: '/rules/soon',
+ name: t('Rules'),
+ path: '/rules',
style: navTabStyle,
Icon: SvgTuning,
},
diff --git a/packages/desktop-client/src/components/mobile/rules/AddRuleButton.tsx b/packages/desktop-client/src/components/mobile/rules/AddRuleButton.tsx
new file mode 100644
index 0000000000..969f641053
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/rules/AddRuleButton.tsx
@@ -0,0 +1,63 @@
+import React from 'react';
+
+import { Button } from '@actual-app/components/button';
+import { SvgAdd } from '@actual-app/components/icons/v1';
+
+import { type NewRuleEntity } from 'loot-core/types/models';
+
+import { pushModal } from '@desktop-client/modals/modalsSlice';
+import { useDispatch } from '@desktop-client/redux';
+
+type AddRuleButtonProps = {
+ onRuleAdded: () => void;
+};
+
+export function AddRuleButton({ onRuleAdded }: AddRuleButtonProps) {
+ const dispatch = useDispatch();
+
+ const handleAddRule = () => {
+ const newRule: NewRuleEntity = {
+ stage: 'pre',
+ conditionsOp: 'and',
+ conditions: [
+ {
+ field: 'payee',
+ op: 'is',
+ value: '',
+ type: 'id',
+ },
+ ],
+ actions: [
+ {
+ field: 'category',
+ op: 'set',
+ value: '',
+ type: 'id',
+ },
+ ],
+ };
+
+ dispatch(
+ pushModal({
+ modal: {
+ name: 'edit-rule',
+ options: {
+ rule: newRule,
+ onSave: onRuleAdded,
+ },
+ },
+ }),
+ );
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/desktop-client/src/components/mobile/rules/MobileRulesPage.tsx b/packages/desktop-client/src/components/mobile/rules/MobileRulesPage.tsx
new file mode 100644
index 0000000000..2350b14546
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/rules/MobileRulesPage.tsx
@@ -0,0 +1,162 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { styles } from '@actual-app/components/styles';
+import { theme } from '@actual-app/components/theme';
+import { View } from '@actual-app/components/view';
+
+import { send } from 'loot-core/platform/client/fetch';
+import { getNormalisedString } from 'loot-core/shared/normalisation';
+import { q } from 'loot-core/shared/query';
+import { type RuleEntity } from 'loot-core/types/models';
+
+import { AddRuleButton } from './AddRuleButton';
+import { RulesList } from './RulesList';
+
+import { Search } from '@desktop-client/components/common/Search';
+import { ruleToString } from '@desktop-client/components/ManageRules';
+import { MobilePageHeader, Page } from '@desktop-client/components/Page';
+import { useAccounts } from '@desktop-client/hooks/useAccounts';
+import { useCategories } from '@desktop-client/hooks/useCategories';
+import { usePayees } from '@desktop-client/hooks/usePayees';
+import { useSchedules } from '@desktop-client/hooks/useSchedules';
+import { pushModal } from '@desktop-client/modals/modalsSlice';
+import { useDispatch } from '@desktop-client/redux';
+
+const PAGE_SIZE = 50;
+
+export function MobileRulesPage() {
+ const { t } = useTranslation();
+ const dispatch = useDispatch();
+ const [allRules, setAllRules] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [hasMoreRules, setHasMoreRules] = useState(true);
+ const [filter, setFilter] = useState('');
+
+ const { schedules = [] } = useSchedules({
+ query: useMemo(() => q('schedules').select('*'), []),
+ });
+ const { list: categories } = useCategories();
+ const payees = usePayees();
+ const accounts = useAccounts();
+ const filterData = useMemo(
+ () => ({
+ payees,
+ accounts,
+ schedules,
+ categories,
+ }),
+ [payees, accounts, schedules, categories],
+ );
+
+ const filteredRules = useMemo(() => {
+ const rules = allRules.filter(rule => {
+ const schedule = schedules.find(schedule => schedule.rule === rule.id);
+ return schedule ? schedule.completed === false : true;
+ });
+
+ return filter === ''
+ ? rules
+ : rules.filter(rule =>
+ getNormalisedString(ruleToString(rule, filterData)).includes(
+ getNormalisedString(filter),
+ ),
+ );
+ }, [allRules, filter, filterData, schedules]);
+
+ const loadRules = useCallback(async (append = false) => {
+ try {
+ setIsLoading(true);
+ const result = await send('rules-get');
+ const newRules = result || [];
+
+ setAllRules(prevRules =>
+ append ? [...prevRules, ...newRules] : newRules,
+ );
+ setHasMoreRules(newRules.length === PAGE_SIZE);
+ } catch (error) {
+ console.error('Failed to load rules:', error);
+ setAllRules([]);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadRules();
+ }, [loadRules]);
+
+ const handleRulePress = (rule: RuleEntity) => {
+ dispatch(
+ pushModal({
+ modal: {
+ name: 'edit-rule',
+ options: {
+ rule,
+ onSave: () => loadRules(),
+ },
+ },
+ }),
+ );
+ };
+
+ const handleLoadMore = useCallback(() => {
+ if (!isLoading && hasMoreRules && !filter) {
+ loadRules(true);
+ }
+ }, [isLoading, hasMoreRules, filter, loadRules]);
+
+ const handleRuleAdded = () => {
+ loadRules();
+ };
+
+ const onSearchChange = useCallback(
+ (value: string) => {
+ setFilter(value);
+ },
+ [setFilter],
+ );
+
+ return (
+ }
+ />
+ }
+ padding={0}
+ >
+
+
+
+
+
+ );
+}
diff --git a/packages/desktop-client/src/components/mobile/rules/RulesList.tsx b/packages/desktop-client/src/components/mobile/rules/RulesList.tsx
new file mode 100644
index 0000000000..9f2c594c1d
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/rules/RulesList.tsx
@@ -0,0 +1,101 @@
+import { type UIEvent } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
+import { Text } from '@actual-app/components/text';
+import { theme } from '@actual-app/components/theme';
+import { View } from '@actual-app/components/view';
+
+import { type RuleEntity } from 'loot-core/types/models';
+
+import { RulesListItem } from './RulesListItem';
+
+import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
+
+type RulesListProps = {
+ rules: RuleEntity[];
+ isLoading: boolean;
+ onRulePress: (rule: RuleEntity) => void;
+ onLoadMore?: () => void;
+};
+
+export function RulesList({
+ rules,
+ isLoading,
+ onRulePress,
+ onLoadMore,
+}: RulesListProps) {
+ const { t } = useTranslation();
+
+ if (isLoading && rules.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (rules.length === 0) {
+ return (
+
+
+ {t('No rules found. Create your first rule to get started!')}
+
+
+ );
+ }
+
+ const handleScroll = (event: UIEvent) => {
+ if (!onLoadMore) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ if (scrollHeight - scrollTop <= clientHeight * 1.5) {
+ onLoadMore();
+ }
+ };
+
+ return (
+
+ {rules.map(rule => (
+ onRulePress(rule)}
+ />
+ ))}
+ {isLoading && (
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/mobile/rules/RulesListItem.tsx b/packages/desktop-client/src/components/mobile/rules/RulesListItem.tsx
new file mode 100644
index 0000000000..f4400f8bc6
--- /dev/null
+++ b/packages/desktop-client/src/components/mobile/rules/RulesListItem.tsx
@@ -0,0 +1,197 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Button } from '@actual-app/components/button';
+import { theme } from '@actual-app/components/theme';
+import { View } from '@actual-app/components/view';
+
+import { type RuleEntity } from 'loot-core/types/models';
+
+import { ActionExpression } from '@desktop-client/components/rules/ActionExpression';
+import { ConditionExpression } from '@desktop-client/components/rules/ConditionExpression';
+import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
+
+const ROW_HEIGHT = 60;
+
+type RulesListItemProps = {
+ rule: RuleEntity;
+ onPress: () => void;
+};
+
+export function RulesListItem({ rule, onPress }: RulesListItemProps) {
+ const { t } = useTranslation();
+
+ // Group actions by splitIndex to handle split transactions
+ const actionSplits = groupActionsBySplitIndex(rule.actions);
+ const hasSplits = actionSplits.length > 1;
+
+ return (
+
+ );
+}
diff --git a/packages/desktop-client/src/components/responsive/narrow.ts b/packages/desktop-client/src/components/responsive/narrow.ts
index d1d2b5cb42..fbae63012e 100644
--- a/packages/desktop-client/src/components/responsive/narrow.ts
+++ b/packages/desktop-client/src/components/responsive/narrow.ts
@@ -2,3 +2,5 @@ export { Budget } from '../mobile/budget';
export { Accounts } from '../mobile/accounts/Accounts';
export { Account } from '../mobile/accounts/Account';
+
+export { MobileRulesPage as Rules } from '../mobile/rules/MobileRulesPage';
diff --git a/packages/desktop-client/src/components/responsive/wide.ts b/packages/desktop-client/src/components/responsive/wide.ts
index 94eb01e43c..1e1df84e0d 100644
--- a/packages/desktop-client/src/components/responsive/wide.ts
+++ b/packages/desktop-client/src/components/responsive/wide.ts
@@ -7,4 +7,6 @@ export { GoCardlessLink } from '../gocardless/GoCardlessLink';
export { Account as Accounts } from '../accounts/Account';
export { Account } from '../accounts/Account';
+export { ManageRulesPage as Rules } from '../ManageRulesPage';
+
export { UserDirectoryPage } from '../admin/UserDirectory/UserDirectoryPage';
diff --git a/packages/desktop-client/src/components/rules/RuleRow.tsx b/packages/desktop-client/src/components/rules/RuleRow.tsx
index 6d0951e001..ae5b577bfa 100644
--- a/packages/desktop-client/src/components/rules/RuleRow.tsx
+++ b/packages/desktop-client/src/components/rules/RuleRow.tsx
@@ -11,7 +11,6 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
-import { v4 as uuid } from 'uuid';
import { friendlyOp, translateRuleStage } from 'loot-core/shared/rules';
import { type RuleEntity } from 'loot-core/types/models';
@@ -22,6 +21,7 @@ import { ConditionExpression } from './ConditionExpression';
import { SelectCell, Row, Field, Cell } from '@desktop-client/components/table';
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useSelectedDispatch } from '@desktop-client/hooks/useSelected';
+import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
type RuleRowProps = {
rule: RuleEntity;
@@ -45,15 +45,7 @@ export const RuleRow = memo(
const borderColor = selected ? theme.tableBorderSelected : 'none';
const backgroundFocus = hovered;
- const actionSplits = rule.actions.reduce(
- (acc, action) => {
- const splitIndex = action['options']?.splitIndex ?? 0;
- acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
- acc[splitIndex].actions.push(action);
- return acc;
- },
- [] as { id: string; actions: RuleEntity['actions'] }[],
- );
+ const actionSplits = groupActionsBySplitIndex(rule.actions);
const hasSplits = actionSplits.length > 1;
const hasSchedule = rule.actions.some(({ op }) => op === 'link-schedule');
diff --git a/packages/desktop-client/src/util/ruleUtils.ts b/packages/desktop-client/src/util/ruleUtils.ts
new file mode 100644
index 0000000000..d0b4e9dab3
--- /dev/null
+++ b/packages/desktop-client/src/util/ruleUtils.ts
@@ -0,0 +1,20 @@
+import { v4 as uuid } from 'uuid';
+
+import { type RuleEntity } from 'loot-core/types/models';
+
+export type ActionSplit = {
+ id: string;
+ actions: RuleEntity['actions'];
+};
+
+export function groupActionsBySplitIndex(
+ actions: RuleEntity['actions'],
+): ActionSplit[] {
+ return actions.reduce((acc, action) => {
+ const splitIndex =
+ 'options' in action ? (action.options?.splitIndex ?? 0) : 0;
+ acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
+ acc[splitIndex].actions.push(action);
+ return acc;
+ }, [] as ActionSplit[]);
+}
diff --git a/upcoming-release-notes/5390.md b/upcoming-release-notes/5390.md
new file mode 100644
index 0000000000..133ffabef0
--- /dev/null
+++ b/upcoming-release-notes/5390.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [MatissJanis]
+---
+
+Add mobile rules page for viewing and managing rules on mobile devices.
\ No newline at end of file