Add swipe to delete to mobile rules (#5871)

This commit is contained in:
Matiss Janis Aboltins
2025-10-07 20:33:46 +02:00
committed by GitHub
parent dc811552be
commit f7b40fca64
12 changed files with 123 additions and 11 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -7,12 +7,14 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/fetch';
import { q } from 'loot-core/shared/query';
import { type RuleEntity, type NewRuleEntity } from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
import { RuleEditor } from '@desktop-client/components/rules/RuleEditor';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -27,6 +29,21 @@ export function MobileRuleEditPage() {
const [rule, setRule] = useState<RuleEntity | null>(null);
const [isLoading, setIsLoading] = useState(false);
const { schedules = [] } = useSchedules({
query: useMemo(
() =>
rule?.id
? q('schedules')
.filter({ rule: rule.id, completed: false })
.select('*')
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule
[rule?.id],
),
});
// Check if the current rule is linked to a schedule
const isLinkedToSchedule = schedules.length > 0;
// Load rule by ID if we're in edit mode
useEffect(() => {
if (id && id !== 'new') {
@@ -174,7 +191,7 @@ export function MobileRuleEditPage() {
rule={defaultRule}
onSave={handleSave}
onCancel={handleCancel}
onDelete={isEditing ? handleDelete : undefined}
onDelete={isEditing && !isLinkedToSchedule ? handleDelete : undefined}
style={{
paddingTop: 10,
flex: 1,

View File

@@ -5,7 +5,8 @@ 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 { send, listen } from 'loot-core/platform/client/fetch';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
import { type RuleEntity } from 'loot-core/types/models';
@@ -21,11 +22,16 @@ import { useCategories } from '@desktop-client/hooks/useCategories';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
export function MobileRulesPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const { showUndoNotification } = useUndo();
const [visibleRulesParam] = useUrlParam('visible-rules');
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -91,6 +97,20 @@ export function MobileRulesPage() {
loadRules();
}, [loadRules]);
// Listen for undo events to refresh rules list
useEffect(() => {
const onUndo = () => {
loadRules();
};
const lastUndoEvent = undo.getUndoState('undoEvent');
if (lastUndoEvent) {
onUndo();
}
return listen('undo-event', onUndo);
}, [loadRules]);
const handleRulePress = useCallback(
(rule: RuleEntity) => {
navigate(`/rules/${rule.id}`);
@@ -105,6 +125,47 @@ export function MobileRulesPage() {
[setFilter],
);
const handleRuleDelete = useCallback(
async (rule: RuleEntity) => {
try {
const { someDeletionsFailed } = await send('rule-delete-all', [
rule.id,
]);
if (someDeletionsFailed) {
dispatch(
addNotification({
notification: {
type: 'warning',
message: t(
'This rule could not be deleted because it is linked to a schedule.',
),
},
}),
);
} else {
showUndoNotification({
message: t('Rule deleted successfully'),
});
}
// Refresh the rules list
await loadRules();
} catch (error) {
console.error('Failed to delete rule:', error);
dispatch(
addNotification({
notification: {
type: 'error',
message: t('Failed to delete rule. Please try again.'),
},
}),
);
}
},
[dispatch, showUndoNotification, t, loadRules],
);
return (
<Page
header={
@@ -140,6 +201,7 @@ export function MobileRulesPage() {
rules={filteredRules}
isLoading={isLoading}
onRulePress={handleRulePress}
onRuleDelete={handleRuleDelete}
/>
</Page>
);

View File

@@ -16,9 +16,15 @@ type RulesListProps = {
rules: RuleEntity[];
isLoading: boolean;
onRulePress: (rule: RuleEntity) => void;
onRuleDelete: (rule: RuleEntity) => void;
};
export function RulesList({ rules, isLoading, onRulePress }: RulesListProps) {
export function RulesList({
rules,
isLoading,
onRulePress,
onRuleDelete,
}: RulesListProps) {
const { t } = useTranslation();
if (isLoading && rules.length === 0) {
@@ -72,7 +78,11 @@ export function RulesList({ rules, isLoading, onRulePress }: RulesListProps) {
}}
>
{rule => (
<RulesListItem value={rule} onAction={() => onRulePress(rule)} />
<RulesListItem
value={rule}
onAction={() => onRulePress(rule)}
onDelete={() => onRuleDelete(rule)}
/>
)}
</GridList>
{isLoading && (

View File

@@ -1,7 +1,8 @@
import React from 'react';
import { GridListItem, type GridListItemProps } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { type GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SpaceBetween } from '@actual-app/components/space-between';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
@@ -10,14 +11,18 @@ import { View } from '@actual-app/components/view';
import { type RuleEntity } from 'loot-core/types/models';
import { type WithRequired } from 'loot-core/types/util';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { ActionExpression } from '@desktop-client/components/rules/ActionExpression';
import { ConditionExpression } from '@desktop-client/components/rules/ConditionExpression';
import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
type RulesListItemProps = WithRequired<GridListItemProps<RuleEntity>, 'value'>;
type RulesListItemProps = {
onDelete: () => void;
} & WithRequired<GridListItemProps<RuleEntity>, 'value'>;
export function RulesListItem({
value: rule,
onDelete,
style,
...props
}: RulesListItemProps) {
@@ -28,11 +33,23 @@ export function RulesListItem({
const hasSplits = actionSplits.length > 1;
return (
<GridListItem
<ActionableGridListItem
id={rule.id}
value={rule}
textValue={t('Rule {{id}}', { id: rule.id })}
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
actions={
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
width: '100%',
}}
>
<Trans>Delete</Trans>
</Button>
}
{...props}
>
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
@@ -182,6 +199,6 @@ export function RulesListItem({
</View>
</View>
</SpaceBetween>
</GridListItem>
</ActionableGridListItem>
);
}

View File

@@ -86,7 +86,7 @@ app.method('rule-validate', ruleValidate);
app.method('rule-add', mutator(addRule));
app.method('rule-update', mutator(updateRule));
app.method('rule-delete', mutator(deleteRule));
app.method('rule-delete-all', mutator(deleteAllRules));
app.method('rule-delete-all', mutator(undoable(deleteAllRules)));
app.method('rule-apply-actions', mutator(undoable(applyRuleActions)));
app.method('rule-add-payee-rename', mutator(addRulePayeeRename));
app.method('rules-get', getRules);

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Add swipe-to-delete functionality for mobile rules with undo support in the UI.