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