[AI] Keep mobile payee/rule filter (#8977)

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Mats Nilsson
2026-09-28 19:55:27 +00:00
committed by GitHub
co-authored by Claude Opus 5
parent 815286d253
commit dd32a55506
8 changed files with 139 additions and 25 deletions
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
import { useLocation, useParams } from 'react-router';
import { Button } from '@actual-app/components/button';
import { styles } from '@actual-app/components/styles';
@@ -23,7 +23,10 @@ export function MobilePayeeEditPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const location = useLocation();
const dispatch = useDispatch();
// Carries the payees list filter so it survives the round trip.
const payeesListPath = `/payees${location.search}`;
const { showUndoNotification } = useUndo();
const { data: payees = [] } = usePayees();
@@ -42,10 +45,10 @@ export function MobilePayeeEditPage() {
setIsLoading(false);
} else {
// Payee not found, navigate back to payees list
void navigate('/payees');
void navigate(payeesListPath);
}
}
}, [id, payees, navigate]);
}, [id, payees, navigate, payeesListPath]);
const handleCancel = useCallback(() => {
void navigate(-1);
@@ -66,7 +69,7 @@ export function MobilePayeeEditPage() {
newName: editedPayeeName.trim(),
}),
});
void navigate('/payees');
void navigate(payeesListPath);
} catch (error) {
console.error('Failed to update payee:', error);
dispatch(
@@ -78,7 +81,15 @@ export function MobilePayeeEditPage() {
}),
);
}
}, [payee, editedPayeeName, dispatch, showUndoNotification, t, navigate]);
}, [
payee,
editedPayeeName,
dispatch,
showUndoNotification,
t,
navigate,
payeesListPath,
]);
// Show loading state while fetching payee
if (isLoading) {
@@ -1,4 +1,5 @@
import React from 'react';
import { MemoryRouter, useLocation } from 'react-router';
import type { PayeeEntity } from '@actual-app/core/types/models';
import { render, screen } from '@testing-library/react';
@@ -33,6 +34,11 @@ const mockPayees: PayeeEntity[] = [
},
];
function LocationSearch() {
const location = useLocation();
return <div data-testid="location-search">{location.search}</div>;
}
describe('MobilePayeesPage', () => {
const mockNavigate = vi.fn();
const queryClient = createTestQueryClient();
@@ -52,10 +58,13 @@ describe('MobilePayeesPage', () => {
);
});
const renderPayeesPage = () => {
const renderPayeesPage = (initialEntry = '/payees') => {
return render(
<TestProviders queryClient={queryClient}>
<MobilePayeesPage />
<MemoryRouter initialEntries={[initialEntry]}>
<MobilePayeesPage />
<LocationSearch />
</MemoryRouter>
</TestProviders>,
);
};
@@ -138,6 +147,37 @@ describe('MobilePayeesPage', () => {
expect(mockNavigate).toBeCalledWith('/payees/payee-1');
});
it('keeps the filter in the URL so it survives leaving the page', async () => {
const user = userEvent.setup();
renderPayeesPage();
const searchBox = screen.getByPlaceholderText(/filter payees/i);
await user.type(searchBox, 'Grocery');
expect(screen.getByTestId('location-search')).toHaveTextContent(
'filter=Grocery',
);
});
it('restores the filter from the URL', () => {
renderPayeesPage('/payees?filter=Grocery');
expect(screen.getByPlaceholderText(/filter payees/i)).toHaveValue(
'Grocery',
);
expect(screen.getByText('Grocery Store')).toBeInTheDocument();
expect(screen.queryByText('Gas Station')).not.toBeInTheDocument();
});
it('carries the filter to the payee edit page', async () => {
const user = userEvent.setup();
renderPayeesPage('/payees?filter=Grocery');
await user.click(screen.getByText('Grocery Store'));
expect(mockNavigate).toBeCalledWith('/payees/payee-1?filter=Grocery');
});
it('shows empty state when no payees match filter', async () => {
const user = userEvent.setup();
renderPayeesPage();
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -9,11 +10,13 @@ import { getNormalisedString } from '@actual-app/core/shared/normalisation';
import type { PayeeEntity, RuleEntity } from '@actual-app/core/types/models';
import { Search } from '#components/common/Search';
import { withFilterParam } from '#components/mobile/utils';
import { MobilePageHeader, Page } from '#components/Page';
import { useNavigate } from '#hooks/useNavigate';
import { usePayeeRuleCounts } from '#hooks/usePayeeRuleCounts';
import { usePayees } from '#hooks/usePayees';
import { useUndo } from '#hooks/useUndo';
import { useUrlParam } from '#hooks/useUrlParam';
import { addNotification } from '#notifications/notificationsSlice';
import { useDispatch } from '#redux';
@@ -23,9 +26,21 @@ export function MobilePayeesPage() {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const { data: payees = [], isPending } = usePayees();
const { showUndoNotification } = useUndo();
const [filter, setFilter] = useState('');
// Keep the filter in the URL so it is restored when navigating back from a
// payee. Local state drives rendering because URL updates are applied in a
// React transition, which would let the list lag behind what was typed.
const [filterParam, setFilterParam] = useUrlParam('filter');
const [filter, setFilter] = useState(filterParam ?? '');
// A query-only navigation can change the filter param while this page stays
// mounted, which would leave the local state (and the list) stale.
useEffect(() => {
setFilter(filterParam ?? '');
}, [filterParam]);
const { data: ruleCounts = new Map(), isPending: isRuleCountsLoading } =
usePayeeRuleCounts();
@@ -35,15 +50,21 @@ export function MobilePayeesPage() {
return payees.filter(p => getNormalisedString(p.name).includes(norm));
}, [payees, filter]);
const onSearchChange = useCallback((value: string) => {
setFilter(value);
}, []);
const onSearchChange = useCallback(
(value: string) => {
setFilter(value);
setFilterParam(value, { replace: true });
},
[setFilterParam],
);
const handlePayeePress = useCallback(
(payee: PayeeEntity) => {
void navigate(`/payees/${payee.id}`);
void navigate(
`/payees/${payee.id}${withFilterParam(location.search, filter)}`,
);
},
[navigate],
[navigate, location.search, filter],
);
const handlePayeeRuleAction = useCallback(
@@ -1,5 +1,6 @@
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { Button } from '@actual-app/components/button';
import { SvgAdd } from '@actual-app/components/icons/v1';
@@ -9,10 +10,12 @@ import { useNavigate } from '#hooks/useNavigate';
export function AddRuleButton() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const handleAddRule = useCallback(() => {
void navigate('/rules/new');
}, [navigate]);
// Carry the rules list filter so it is restored when coming back.
void navigate(`/rules/new${location.search}`);
}, [navigate, location.search]);
return (
<Button
@@ -26,6 +26,8 @@ export function MobileRuleEditPage() {
const location = useLocation();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
// Carries the rules list filter so it survives the round trip.
const rulesListPath = `/rules${location.search}`;
const [rule, setRule] = useState<RuleEntity | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -55,19 +57,19 @@ export function MobileRuleEditPage() {
setRule(loadedRule);
} else {
// Rule not found, navigate back to rules list
void navigate('/rules');
void navigate(rulesListPath);
}
})
.catch(error => {
console.error('Failed to load rule:', error);
// Navigate back to rules list if rule not found
void navigate('/rules');
void navigate(rulesListPath);
})
.finally(() => {
setIsLoading(false);
});
}
}, [id, navigate]);
}, [id, navigate, rulesListPath]);
// If no rule is provided, create a new one
const defaultRule: NewRuleEntity = rule || {
@@ -99,7 +101,7 @@ export function MobileRuleEditPage() {
});
}
// Navigate back to rules list
void navigate('/rules');
void navigate(rulesListPath);
};
const handleCancel = () => {
@@ -124,7 +126,7 @@ export function MobileRuleEditPage() {
showUndoNotification({
message: t('Rule deleted successfully'),
});
void navigate('/rules');
void navigate(rulesListPath);
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -12,6 +13,7 @@ import type { RuleEntity } from '@actual-app/core/types/models';
import { Search } from '#components/common/Search';
import { ruleToString } from '#components/ManageRules';
import { withFilterParam } from '#components/mobile/utils';
import { MobilePageHeader, Page } from '#components/Page';
import { useAccounts } from '#hooks/useAccounts';
import { useCategories } from '#hooks/useCategories';
@@ -29,12 +31,23 @@ import { RulesList } from './RulesList';
export function MobileRulesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const [visibleRulesParam] = useUrlParam('visible-rules');
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
// Keep the filter in the URL so it is restored when navigating back from a
// rule. Local state drives rendering because URL updates are applied in a
// React transition, which would let the list lag behind what was typed.
const [filterParam, setFilterParam] = useUrlParam('filter');
const [filter, setFilter] = useState(filterParam ?? '');
// A query-only navigation can change the filter param while this page stays
// mounted, which would leave the local state (and the list) stale.
useEffect(() => {
setFilter(filterParam ?? '');
}, [filterParam]);
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
@@ -112,16 +125,19 @@ export function MobileRulesPage() {
const handleRulePress = useCallback(
(rule: RuleEntity) => {
void navigate(`/rules/${rule.id}`);
void navigate(
`/rules/${rule.id}${withFilterParam(location.search, filter)}`,
);
},
[navigate],
[navigate, location.search, filter],
);
const onSearchChange = useCallback(
(value: string) => {
setFilter(value);
setFilterParam(value, { replace: true });
},
[setFilter],
[setFilterParam],
);
const handleRuleDelete = useCallback(
@@ -36,3 +36,18 @@ export function getPrettyPayee({
return '';
}
/**
* Returns `search` with the `filter` param set to `filter` — or removed when
* it is empty — leaving any other params intact.
*/
export function withFilterParam(search: string, filter: string) {
const params = new URLSearchParams(search);
if (filter) {
params.set('filter', filter);
} else {
params.delete('filter');
}
const next = params.toString();
return next ? `?${next}` : '';
}
@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [mnil]
---
Keep the filter on the mobile payees and rules lists when opening and leaving a payee or rule.