Mobile rules page (#5390)

This commit is contained in:
Matiss Janis Aboltins
2025-08-06 23:00:08 +01:00
committed by GitHub
parent 186d417c6e
commit 6a9028464b
33 changed files with 803 additions and 26 deletions

View File

@@ -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',

View File

@@ -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();
}
}

View File

@@ -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();
});
});

View File

@@ -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() {
/>
<Route path="/payees" element={<ManagePayeesPage />} />
<Route path="/rules" element={<ManageRulesPage />} />
<Route
path="/rules"
element={<NarrowAlternate name="Rules" />}
/>
<Route path="/bank-sync" element={<BankSync />} />
<Route path="/tags" element={<ManageTagsPage />} />
<Route path="/settings" element={<Settings />} />
@@ -331,6 +333,7 @@ export function FinancesApp() {
<Route path="/accounts" element={<MobileNavTabs />} />
<Route path="/settings" element={<MobileNavTabs />} />
<Route path="/reports" element={<MobileNavTabs />} />
<Route path="/rules" element={<MobileNavTabs />} />
<Route path="*" element={null} />
</Routes>
</ScrollProvider>

View File

@@ -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<FilterData>,
) {
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 [];
}

View File

@@ -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,
},

View File

@@ -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 (
<Button
variant="bare"
aria-label="Add new rule"
style={{ margin: 10 }}
onPress={handleAddRule}
>
<SvgAdd width={20} height={20} />
</Button>
);
}

View File

@@ -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<RuleEntity[]>([]);
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 (
<Page
header={
<MobilePageHeader
title={t('Rules')}
rightContent={<AddRuleButton onRuleAdded={handleRuleAdded} />}
/>
}
padding={0}
>
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search
placeholder={t('Filter rules…')}
value={filter}
onChange={onSearchChange}
width="100%"
height={styles.mobileMinHeight}
style={{
backgroundColor: theme.tableBackground,
borderColor: theme.formInputBorder,
}}
/>
</View>
<RulesList
rules={filteredRules}
isLoading={isLoading}
onRulePress={handleRulePress}
onLoadMore={handleLoadMore}
/>
</Page>
);
}

View File

@@ -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 (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 100,
}}
>
<AnimatedLoading style={{ width: 25, height: 25 }} />
</View>
);
}
if (rules.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
{t('No rules found. Create your first rule to get started!')}
</Text>
</View>
);
}
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
if (!onLoadMore) return;
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
if (scrollHeight - scrollTop <= clientHeight * 1.5) {
onLoadMore();
}
};
return (
<View
style={{ flex: 1, paddingBottom: MOBILE_NAV_HEIGHT, overflow: 'auto' }}
onScroll={handleScroll}
>
{rules.map(rule => (
<RulesListItem
key={rule.id}
rule={rule}
onPress={() => onRulePress(rule)}
/>
))}
{isLoading && (
<View
style={{
alignItems: 'center',
paddingVertical: 20,
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
)}
</View>
);
}

View File

@@ -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 (
<Button
variant="bare"
style={{
minHeight: ROW_HEIGHT,
width: '100%',
borderRadius: 0,
borderWidth: '0 0 1px 0',
borderColor: theme.tableBorder,
borderStyle: 'solid',
backgroundColor: theme.tableBackground,
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'flex-start',
padding: '8px 16px',
gap: 12,
}}
onPress={onPress}
>
{/* Column 1: PRE/POST pill */}
<View
style={{
flexShrink: 0,
paddingTop: 2, // Slight top padding to align with text baseline
}}
>
<View
style={{
backgroundColor:
rule.stage === 'pre'
? theme.noticeBackgroundLight
: rule.stage === 'post'
? theme.warningBackground
: theme.pillBackgroundSelected,
paddingLeft: 6,
paddingRight: 6,
paddingTop: 2,
paddingBottom: 2,
borderRadius: 3,
}}
>
<span
style={{
fontSize: 11,
fontWeight: 500,
color:
rule.stage === 'pre'
? theme.noticeTextLight
: rule.stage === 'post'
? theme.warningText
: theme.pillTextSelected,
}}
>
{rule.stage === 'pre'
? t('PRE')
: rule.stage === 'post'
? t('POST')
: t('DEFAULT')}
</span>
</View>
</View>
{/* Column 2: IF and THEN blocks */}
<View
style={{
flex: 1,
flexDirection: 'column',
gap: 4,
}}
>
{/* IF conditions block */}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: 6,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginRight: 4,
}}
>
{t('IF')}
</span>
{rule.conditions.map((condition, index) => (
<View key={index} style={{ marginRight: 4, marginBottom: 2 }}>
<ConditionExpression
field={condition.field}
op={condition.op}
value={condition.value}
options={condition.options}
inline={true}
/>
</View>
))}
</View>
{/* THEN actions block */}
<View
style={{
flexDirection: 'column',
alignItems: 'flex-start',
gap: 4,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: theme.pageTextLight,
marginBottom: 2,
}}
>
{t('THEN')}
</span>
{hasSplits
? actionSplits.map((split, i) => (
<View
key={split.id}
style={{
width: '100%',
flexDirection: 'column',
alignItems: 'flex-start',
marginTop: i > 0 ? 4 : 0,
padding: '6px',
borderColor: theme.tableBorder,
borderWidth: '1px',
borderRadius: '5px',
}}
>
<span
style={{
fontSize: 11,
fontWeight: 500,
color: theme.pageTextLight,
marginBottom: 4,
}}
>
{i ? t('Split {{num}}', { num: i }) : t('Apply to all')}
</span>
{split.actions.map((action, j) => (
<View
key={j}
style={{
marginBottom: j !== split.actions.length - 1 ? 2 : 0,
maxWidth: '100%',
}}
>
<ActionExpression {...action} />
</View>
))}
</View>
))
: rule.actions.map((action, index) => (
<View key={index} style={{ marginBottom: 2, maxWidth: '100%' }}>
<ActionExpression {...action} />
</View>
))}
</View>
</View>
</Button>
);
}

View File

@@ -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';

View File

@@ -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';

View File

@@ -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');

View File

@@ -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[]);
}