mirror of
https://github.com/actualbudget/actual.git
synced 2026-10-12 02:10:19 +00:00
[AI] Keep mobile payee/rule filter (#8977)
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
815286d253
commit
dd32a55506
@@ -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.
|
||||
Reference in New Issue
Block a user