Mobile rules page (#5390)
@@ -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',
|
||||
|
||||
118
packages/desktop-client/e2e/page-models/mobile-rules-page.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
90
packages/desktop-client/e2e/rules.mobile.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
@@ -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>
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
20
packages/desktop-client/src/util/ruleUtils.ts
Normal 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[]);
|
||||
}
|
||||