Connect automations UI to backend (#5533)

* Connect automations UI to backend

* Fix integer -> amount

* Add release notes

* Fix length check

* No layout shift

* Coderabbit: decimal places
This commit is contained in:
Julian Dominguez-Schatz
2025-08-19 20:30:09 -04:00
committed by GitHub
parent 89e5676cfb
commit dc2ab4843f
16 changed files with 365 additions and 113 deletions

View File

@@ -102,7 +102,9 @@ export function Modals() {
return budgetId ? <GoalTemplateModal key={key} /> : null;
case 'category-automations-edit':
return budgetId ? <BudgetAutomationsModal key={name} /> : null;
return budgetId ? (
<BudgetAutomationsModal key={name} {...modal.options} />
) : null;
case 'keyboard-shortcuts':
// don't show the hotkey help modal when a budget is not open

View File

@@ -14,6 +14,7 @@ import { Popover } from '@actual-app/components/popover';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import { css, cx } from '@emotion/css';
import { send } from 'loot-core/platform/client/fetch';
@@ -27,6 +28,7 @@ type NotesButtonProps = {
height?: number;
defaultColor?: string;
tooltipPosition?: ComponentProps<typeof Tooltip>['placement'];
showPlaceholder?: boolean;
style?: CSSProperties;
};
export function NotesButton({
@@ -35,6 +37,7 @@ export function NotesButton({
height = 12,
defaultColor = theme.buttonNormalText,
tooltipPosition = 'bottom start',
showPlaceholder = false,
style,
}: NotesButtonProps) {
const { t } = useTranslation();
@@ -73,13 +76,19 @@ export function NotesButton({
ref={triggerRef}
variant="bare"
aria-label={t('View notes')}
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
style={{
color: defaultColor,
...style,
...(hasNotes && { display: 'flex !important' }),
...(isOpen && { color: theme.buttonNormalText }),
}}
className={cx(
css({
color: defaultColor,
...style,
...(showPlaceholder && {
opacity: hasNotes || isOpen ? 1 : 0.3,
}),
...(isOpen && { color: theme.buttonNormalText }),
'&:hover': { opacity: 1 },
}),
!hasNotes && !isOpen && !showPlaceholder ? 'hover-visible' : '',
)}
data-placeholder={showPlaceholder}
onPress={() => {
setIsOpen(true);
}}

View File

@@ -15,12 +15,10 @@ import {
type CategoryEntity,
} from 'loot-core/types/models';
import { CategoryAutomationButton } from './goals/CategoryAutomationButton';
import { SidebarCategoryButtons } from './SidebarCategoryButtons';
import { NotesButton } from '@desktop-client/components/NotesButton';
import { InputCell } from '@desktop-client/components/table';
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
type SidebarCategoryProps = {
@@ -56,7 +54,6 @@ export function SidebarCategory({
onHideNewCategory,
}: SidebarCategoryProps) {
const { t } = useTranslation();
const isGoalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const [categoryExpandedStatePref] = useGlobalPref('categoryExpandedState');
const categoryExpandedState = categoryExpandedStatePref ?? 0;
@@ -128,22 +125,11 @@ export function SidebarCategory({
/>
</Popover>
</View>
<View style={{ flex: 1 }} />
{!goalsShown && isGoalTemplatesUIEnabled && (
<View style={{ flexShrink: 0 }}>
<CategoryAutomationButton
style={dragging && { color: 'currentColor' }}
defaultColor={theme.pageTextLight}
/>
</View>
)}
<View style={{ flexShrink: 0 }}>
<NotesButton
id={category.id}
style={dragging && { color: 'currentColor' }}
defaultColor={theme.pageTextLight}
/>
</View>
<SidebarCategoryButtons
category={category}
dragging={dragging}
goalsShown={goalsShown}
/>
</View>
);

View File

@@ -0,0 +1,53 @@
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { type CategoryEntity } from 'loot-core/types/models/category';
import { CategoryAutomationButton } from './goals/CategoryAutomationButton';
import { NotesButton } from '@desktop-client/components/NotesButton';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useNotes } from '@desktop-client/hooks/useNotes';
type SidebarCategoryButtonsProps = {
category: CategoryEntity;
dragging: boolean;
goalsShown: boolean;
};
export const SidebarCategoryButtons = ({
category,
dragging,
goalsShown,
}: SidebarCategoryButtonsProps) => {
const isGoalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const notes = useNotes(category.id) || '';
return (
<>
<View style={{ flex: 1 }} />
{!goalsShown && isGoalTemplatesUIEnabled && (
<View style={{ flexShrink: 0 }}>
<CategoryAutomationButton
category={category}
style={dragging ? { color: 'currentColor' } : undefined}
defaultColor={theme.pageTextLight}
showPlaceholder={!!notes}
/>
</View>
)}
<View style={{ flexShrink: 0 }}>
<NotesButton
id={category.id}
style={dragging ? { color: 'currentColor' } : undefined}
defaultColor={theme.pageTextLight}
showPlaceholder={
!goalsShown &&
isGoalTemplatesUIEnabled &&
!!category.goal_def?.length
}
/>
</View>
</>
);
};

View File

@@ -1,3 +1,4 @@
import { type ReactNode } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { InitialFocus } from '@actual-app/components/initial-focus';
@@ -20,7 +21,11 @@ import { ScheduleAutomation } from './editor/ScheduleAutomation';
import { SimpleAutomation } from './editor/SimpleAutomation';
import { WeekAutomation } from './editor/WeekAutomation';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import {
FormField,
FormLabel,
FormTextLabel,
} from '@desktop-client/components/forms';
type BudgetAutomationEditorProps = {
inline: boolean;
@@ -73,7 +78,7 @@ export function BudgetAutomationEditor({
}: BudgetAutomationEditorProps) {
const { t } = useTranslation();
let automationEditor;
let automationEditor: ReactNode;
switch (state.displayType) {
case 'simple':
automationEditor = (
@@ -144,7 +149,7 @@ export function BudgetAutomationEditor({
</InitialFocus>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel title={t('Description')} />
<FormTextLabel title={t('Description')} />
<Text>
{displayTypeToDescription[state.displayType] ?? (
<Trans>No description available</Trans>

View File

@@ -4,34 +4,36 @@ import { useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgChartPie } from '@actual-app/components/icons/v1';
import { theme } from '@actual-app/components/theme';
import { cx, css } from '@emotion/css';
import { type Template } from 'loot-core/types/models/templates';
import { type CategoryEntity } from 'loot-core/types/models';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
type CategoryAutomationButtonProps = {
category: CategoryEntity;
width?: number;
height?: number;
defaultColor?: string;
style?: CSSProperties;
showPlaceholder?: boolean;
};
export function CategoryAutomationButton({
category,
width = 12,
height = 12,
defaultColor = theme.buttonNormalText,
style,
showPlaceholder = false,
}: CategoryAutomationButtonProps) {
const { t } = useTranslation();
const automations: Template[] = [];
const hasAutomations = !!automations.length;
const dispatch = useDispatch();
const goalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const goalTemplatesUIEnabled = useFeatureFlag('goalTemplatesUIEnabled');
const hasAutomations = !!category.goal_def?.length;
if (!goalTemplatesEnabled || !goalTemplatesUIEnabled) {
return null;
@@ -41,14 +43,26 @@ export function CategoryAutomationButton({
<Button
variant="bare"
aria-label={t('Change category automations')}
className={!hasAutomations ? 'hover-visible' : ''}
style={{
color: defaultColor,
...style,
...(hasAutomations && { display: 'flex !important' }),
}}
className={cx(
!hasAutomations && !showPlaceholder ? 'hover-visible' : '',
css({
color: defaultColor,
opacity: hasAutomations || !showPlaceholder ? 1 : 0.3,
'&:hover': {
opacity: 1,
},
...style,
}),
)}
onPress={() => {
dispatch(pushModal({ modal: { name: 'category-automations-edit' } }));
dispatch(
pushModal({
modal: {
name: 'category-automations-edit',
options: { categoryId: category.id },
},
}),
);
}}
>
<SvgChartPie style={{ width, height, flexShrink: 0 }} />

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
import type { SimpleTemplate } from 'loot-core/types/models/templates';
import {
@@ -8,6 +9,7 @@ import {
} from '@desktop-client/components/budget/goals/actions';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useFormat } from '@desktop-client/hooks/useFormat';
type SimpleAutomationProps = {
template: SimpleTemplate;
@@ -19,6 +21,7 @@ export const SimpleAutomation = ({
dispatch,
}: SimpleAutomationProps) => {
const { t } = useTranslation();
const { currency } = useFormat();
return (
<FormField>
@@ -26,10 +29,15 @@ export const SimpleAutomation = ({
<AmountInput
id="amount-field"
key="amount-input"
value={template.monthly ?? 0}
value={amountToInteger(template.monthly ?? 0, currency.decimalPlaces)}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(updateTemplate({ type: 'simple', monthly: value }))
dispatch(
updateTemplate({
type: 'simple',
monthly: integerToAmount(value, currency.decimalPlaces),
}),
)
}
/>
</FormField>

View File

@@ -1,8 +1,10 @@
import { Trans } from 'react-i18next';
import { integerToCurrency } from 'loot-core/shared/util';
import { amountToInteger } from 'loot-core/shared/util';
import type { SimpleTemplate } from 'loot-core/types/models/templates';
import { useFormat } from '@desktop-client/hooks/useFormat';
type SimpleAutomationReadOnlyProps = {
template: SimpleTemplate;
};
@@ -10,9 +12,17 @@ type SimpleAutomationReadOnlyProps = {
export const SimpleAutomationReadOnly = ({
template,
}: SimpleAutomationReadOnlyProps) => {
const format = useFormat();
return (
<Trans>
Budget {{ monthly: integerToCurrency(template.monthly ?? 0) }} each month
Budget{' '}
{{
monthly: format(
amountToInteger(template.monthly ?? 0, format.currency.decimalPlaces),
'financial',
),
}}{' '}
each month
</Trans>
);
};

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next';
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
import type { PeriodicTemplate } from 'loot-core/types/models/templates';
import {
@@ -8,6 +9,7 @@ import {
} from '@desktop-client/components/budget/goals/actions';
import { FormField, FormLabel } from '@desktop-client/components/forms';
import { AmountInput } from '@desktop-client/components/util/AmountInput';
import { useFormat } from '@desktop-client/hooks/useFormat';
type WeekAutomationProps = {
template: PeriodicTemplate;
@@ -16,6 +18,7 @@ type WeekAutomationProps = {
export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
const { t } = useTranslation();
const { currency } = useFormat();
return (
<FormField style={{ flex: 1 }}>
@@ -23,10 +26,15 @@ export const WeekAutomation = ({ template, dispatch }: WeekAutomationProps) => {
<AmountInput
id="amount-field"
key="amount-input"
value={template.amount ?? 0}
value={amountToInteger(template.amount ?? 0, currency.decimalPlaces)}
zeroSign="+"
onUpdate={(value: number) =>
dispatch(updateTemplate({ type: 'periodic', amount: value }))
dispatch(
updateTemplate({
type: 'periodic',
amount: integerToAmount(value, currency.decimalPlaces),
}),
)
}
/>
</FormField>

View File

@@ -1,8 +1,10 @@
import { Trans } from 'react-i18next';
import { integerToCurrency } from 'loot-core/shared/util';
import { amountToInteger } from 'loot-core/shared/util';
import type { PeriodicTemplate } from 'loot-core/types/models/templates';
import { useFormat } from '@desktop-client/hooks/useFormat';
type WeekAutomationReadOnlyProps = {
template: PeriodicTemplate;
};
@@ -10,9 +12,18 @@ type WeekAutomationReadOnlyProps = {
export const WeekAutomationReadOnly = ({
template,
}: WeekAutomationReadOnlyProps) => {
const format = useFormat();
return (
<Trans>
Budget {{ amount: integerToCurrency(template.amount) }} each week
Budget{' '}
{{
amount: format(
amountToInteger(template.amount, format.currency.decimalPlaces),
'financial',
),
}}{' '}
each week
</Trans>
);
};

View File

@@ -62,7 +62,7 @@ const changeType = (
template: {
directive: 'template',
type: 'simple',
monthly: 500,
monthly: 5,
priority: DEFAULT_PRIORITY,
},
};
@@ -103,7 +103,7 @@ const changeType = (
template: {
directive: 'template',
type: 'periodic',
amount: 500,
amount: 5,
period: {
period: 'week',
amount: 1,

View File

@@ -35,13 +35,17 @@ type FormLabelProps = {
style?: CSSProperties;
};
const defaultLabelStyle: CSSProperties = {
fontSize: 13,
marginBottom: 3,
color: theme.tableText,
};
export const FormLabel = ({ style, title, id, htmlFor }: FormLabelProps) => {
return (
<Text
style={{
fontSize: 13,
marginBottom: 3,
color: theme.tableText,
...defaultLabelStyle,
...style,
}}
>
@@ -52,6 +56,24 @@ export const FormLabel = ({ style, title, id, htmlFor }: FormLabelProps) => {
);
};
export const FormTextLabel = ({
style,
title,
id,
}: Omit<FormLabelProps, 'htmlFor'>) => {
return (
<Text
style={{
...defaultLabelStyle,
cursor: 'default',
...style,
}}
>
<span id={id}>{title}</span>
</Text>
);
};
type FormFieldProps = {
style?: CSSProperties;
children: ReactNode;

View File

@@ -2,12 +2,19 @@ import { useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { Stack } from '@actual-app/components/stack';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { uniqueId } from 'lodash';
import { send } from 'loot-core/platform/client/fetch';
import { q } from 'loot-core/shared/query';
import { type Template } from 'loot-core/types/models/templates';
import {
type CategoryGroupEntity,
type ScheduleEntity,
} from 'loot-core/types/models';
import type { Template } from 'loot-core/types/models/templates';
import { BudgetAutomation } from '@desktop-client/components/budget/goals/BudgetAutomation';
import { DEFAULT_PRIORITY } from '@desktop-client/components/budget/goals/reducer';
@@ -17,24 +24,87 @@ import {
ModalCloseButton,
ModalHeader,
} from '@desktop-client/components/common/Modal';
import { useBudgetAutomations } from '@desktop-client/hooks/useBudgetAutomations';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
type TemplateWithId = Template & { id: string };
function BudgetAutomationList({
automations,
setAutomations,
schedules,
categories,
}: {
automations: Template[];
setAutomations: (fn: (prev: Template[]) => Template[]) => void;
schedules: readonly ScheduleEntity[];
categories: CategoryGroupEntity[];
}) {
const [automationIds, setAutomationIds] = useState(() => {
// automations don't have ids, so we need to generate them
return automations.map(() => uniqueId('automation-'));
});
export function BudgetAutomationsModal() {
const onAdd = () => {
const newId = uniqueId('automation-');
setAutomationIds(prevIds => [...prevIds, newId]);
setAutomations(prev => [
...prev,
{
type: 'simple',
monthly: 5,
directive: 'template',
priority: DEFAULT_PRIORITY,
},
]);
};
const onDelete = (index: number) => () => {
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
setAutomationIds(prev => [
...prev.slice(0, index),
...prev.slice(index + 1),
]);
};
return (
<Stack
spacing={4}
style={{
overflowY: 'scroll',
}}
>
{automations.map((automation, index) => (
<BudgetAutomation
key={automationIds[index]}
onDelete={onDelete(index)}
template={automation}
categories={categories}
schedules={schedules}
readOnlyStyle={{
color: theme.pillText,
backgroundColor: theme.pillBackground,
borderRadius: 4,
padding: 16,
paddingLeft: 30,
paddingRight: 16,
}}
/>
))}
<Button onPress={onAdd}>
<Trans>Add new automation</Trans>
</Button>
</Stack>
);
}
export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
const { t } = useTranslation();
// HACK: This is a placeholder for the actual data.
// We should eventually load it using a data hook.
const [templates, setTemplates] = useState<TemplateWithId[]>([
{
type: 'average',
numMonths: 3,
directive: 'template',
priority: DEFAULT_PRIORITY,
id: uniqueId(),
},
]);
const [automations, setAutomations] = useState<Record<string, Template[]>>(
{},
);
const { loading } = useBudgetAutomations({
categoryId,
onLoaded: setAutomations,
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules } = useSchedules({
@@ -43,21 +113,22 @@ export function BudgetAutomationsModal() {
const categories = useBudgetAutomationCategories();
const onAdd = () => {
setTemplates([
...templates,
{
type: 'average',
numMonths: 3,
directive: 'template',
priority: DEFAULT_PRIORITY,
id: uniqueId(),
},
]);
};
const onSave = () => {};
const onDelete = (index: number) => () => {
setTemplates([...templates.slice(0, index), ...templates.slice(index + 1)]);
const onSave = async (close: () => void) => {
if (!automations[categoryId]) {
close();
return;
}
await send('budget/set-category-automations', {
categoriesWithTemplates: [
{
id: categoryId,
templates: automations[categoryId],
},
],
source: 'ui',
});
close();
};
return (
@@ -68,40 +139,48 @@ export function BudgetAutomationsModal() {
}}
>
{({ state: { close } }) => (
<>
<Stack direction="column" style={{ height: '100%' }}>
<ModalHeader
title={t('Budget automations')}
rightContent={<ModalCloseButton onPress={close} />}
/>
<Stack
spacing={4}
style={{
overflowY: 'scroll',
}}
>
{templates.map((template, index) => (
<BudgetAutomation
key={template.id}
onSave={onSave}
onDelete={onDelete(index)}
template={template}
categories={categories}
schedules={schedules}
readOnlyStyle={{
color: theme.pillText,
backgroundColor: theme.pillBackground,
borderRadius: 4,
padding: 16,
paddingLeft: 30,
paddingRight: 16,
}}
/>
))}
<Button onPress={onAdd}>
<Trans>Add new automation</Trans>
{loading ? (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
}}
>
<AnimatedLoading style={{ width: 20, height: 20 }} />
</View>
) : (
<BudgetAutomationList
automations={automations[categoryId] || []}
setAutomations={(cb: (prev: Template[]) => Template[]) => {
setAutomations(prev => ({
...prev,
[categoryId]: cb(prev[categoryId] || []),
}));
}}
schedules={schedules}
categories={categories}
/>
)}
<View style={{ flexGrow: 1 }} />
<Stack direction="row" justify="flex-end" style={{ marginTop: 20 }}>
<Button onPress={close}>
<Trans>Cancel</Trans>
</Button>
<Button variant="primary" onPress={() => onSave(close)}>
<Trans>Save</Trans>
</Button>
</Stack>
</>
</Stack>
)}
</Modal>
);

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from 'react';
import { send } from 'loot-core/platform/client/fetch';
import type { Template } from 'loot-core/types/models/templates';
export function useBudgetAutomations({
categoryId,
onLoaded,
}: {
categoryId: string;
onLoaded: (automations: Record<string, Template[]>) => void;
}) {
const [automations, setAutomations] = useState<Record<string, Template[]>>(
{},
);
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
async function fetchAutomations() {
setLoading(true);
const result = await send('budget/get-category-automations', categoryId);
if (mounted) {
setAutomations(result);
onLoaded(result);
setLoading(false);
}
}
fetchAutomations();
return () => {
mounted = false;
};
}, [categoryId, onLoaded]);
return { automations, loading };
}

View File

@@ -542,6 +542,9 @@ export type Modal =
}
| {
name: 'category-automations-edit';
options: {
categoryId: CategoryEntity['id'];
};
};
type OpenAccountCloseModalPayload = {