Add swipe to delete to mobile rules (#5871)
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
6
upcoming-release-notes/5871.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add swipe-to-delete functionality for mobile rules with undo support in the UI.
|
||||