Compare commits
12 Commits
pglite
...
mobile-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efaee98d2a | ||
|
|
0b156d1815 | ||
|
|
5e9f38ea45 | ||
|
|
b901e7a6bd | ||
|
|
843e957757 | ||
|
|
073725e270 | ||
|
|
0e20e17fa4 | ||
|
|
f1fd99eeac | ||
|
|
cf3a42792f | ||
|
|
d41af58daf | ||
|
|
25ee19c1e1 | ||
|
|
fed1cd7d30 |
@@ -84,6 +84,78 @@ test.describe('Transactions', () => {
|
||||
);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('by payee', async () => {
|
||||
accountPage = await navigation.goToAccountPage('Capital One Checking');
|
||||
const filterTooltip = await accountPage.filterBy('Payee');
|
||||
const filtersMenuTooltip = page.getByTestId('filters-menu-tooltip');
|
||||
await expect(filterTooltip.locator).toMatchThemeScreenshots();
|
||||
|
||||
// Type in the autocomplete box
|
||||
const autocomplete = filtersMenuTooltip.getByLabel('Payee');
|
||||
await expect(autocomplete).toMatchThemeScreenshots();
|
||||
|
||||
// Open the textbox, auto-open is currently broken for anything that's not "is not"
|
||||
await autocomplete.click();
|
||||
|
||||
await page.getByTestId('Kroger-payee-item').click();
|
||||
await filterTooltip.applyButton.click();
|
||||
|
||||
// Assert that all Payees are Kroger
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).toHaveText(
|
||||
'Kroger',
|
||||
);
|
||||
}
|
||||
await accountPage.removeFilter(0);
|
||||
|
||||
await accountPage.filterBy('Payee');
|
||||
await filtersMenuTooltip
|
||||
.getByRole('button', { name: 'contains' })
|
||||
.click();
|
||||
const textInput = filtersMenuTooltip.getByPlaceholder('nothing');
|
||||
|
||||
await textInput.fill('De');
|
||||
await filterTooltip.applyButton.click();
|
||||
// Assert that all Payees are Deposit
|
||||
for (let i = 0; i < 9; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).toHaveText(
|
||||
'Deposit',
|
||||
);
|
||||
}
|
||||
|
||||
await accountPage.removeFilter(0);
|
||||
|
||||
await accountPage.filterBy('Payee');
|
||||
await filtersMenuTooltip
|
||||
.getByRole('button', { name: 'contains' })
|
||||
.click();
|
||||
|
||||
await textInput.fill('l');
|
||||
await filterTooltip.applyButton.click();
|
||||
// Assert that both Payees contain the letter 'l'
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).toHaveText(/l/);
|
||||
}
|
||||
|
||||
await accountPage.removeFilter(0);
|
||||
|
||||
await accountPage.filterBy('Payee');
|
||||
await filtersMenuTooltip
|
||||
.getByRole('button', { name: 'does not contain' })
|
||||
.click();
|
||||
|
||||
await textInput.fill('l');
|
||||
await filterTooltip.applyButton.click();
|
||||
// Assert that all Payees DO NOT contain the letter 'l'
|
||||
for (let i = 0; i < 19; i++) {
|
||||
await expect(accountPage.getNthTransaction(i).payee).not.toHaveText(
|
||||
/l/,
|
||||
);
|
||||
}
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a test transaction', async () => {
|
||||
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 936 B |
|
After Width: | Height: | Size: 1023 B |
|
After Width: | Height: | Size: 992 B |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 95 KiB |
@@ -79,7 +79,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.9.6",
|
||||
"react-router": "7.12.0",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "10.0.0",
|
||||
"react-swipeable": "^7.0.2",
|
||||
|
||||
@@ -126,6 +126,10 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
return value;
|
||||
}, [value, field, subfield, dateFormat]);
|
||||
|
||||
// For ops that filter based on payeeId, those use PayeeFilter, otherwise we use GenericInput
|
||||
const isPayeeIdOp = (op: T['op']) =>
|
||||
['is', 'is not', 'one of', 'not one of'].includes(op);
|
||||
|
||||
return (
|
||||
<FocusScope>
|
||||
<View style={{ marginBottom: 10 }}>
|
||||
@@ -260,7 +264,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{type !== 'boolean' && field !== 'payee' && (
|
||||
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
@@ -292,7 +296,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
/>
|
||||
)}
|
||||
|
||||
{field === 'payee' && (
|
||||
{field === 'payee' && isPayeeIdOp(op) && (
|
||||
<PayeeFilter
|
||||
// @ts-expect-error - fix me
|
||||
value={formattedValue}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type DragItem } from 'react-aria';
|
||||
import { isTextDropItem, type DragItem } from 'react-aria';
|
||||
import { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -13,8 +13,11 @@ import {
|
||||
import { ExpenseCategoryListItem } from './ExpenseCategoryListItem';
|
||||
|
||||
import { moveCategory } from '@desktop-client/budget/budgetSlice';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
const DRAG_TYPE = 'mobile-expense-category-list/category-id';
|
||||
|
||||
type ExpenseCategoryListProps = {
|
||||
categoryGroup: CategoryGroupEntity;
|
||||
categories: CategoryEntity[];
|
||||
@@ -37,14 +40,14 @@ export function ExpenseCategoryList({
|
||||
shouldHideCategory,
|
||||
}: ExpenseCategoryListProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
const { reorderCategory } = useReorderCategory();
|
||||
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: keys =>
|
||||
[...keys].map(
|
||||
key =>
|
||||
({
|
||||
'text/plain': key as CategoryEntity['id'],
|
||||
[DRAG_TYPE]: key as CategoryEntity['id'],
|
||||
}) as DragItem,
|
||||
),
|
||||
renderDropIndicator: target => {
|
||||
@@ -54,7 +57,7 @@ export function ExpenseCategoryList({
|
||||
className={css({
|
||||
'&[data-drop-target]': {
|
||||
height: 4,
|
||||
backgroundColor: theme.tableBorderSeparator,
|
||||
backgroundColor: theme.tableBorderHover,
|
||||
opacity: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
@@ -62,59 +65,25 @@ export function ExpenseCategoryList({
|
||||
/>
|
||||
);
|
||||
},
|
||||
acceptedDragTypes: [DRAG_TYPE],
|
||||
getDropOperation: () => 'move',
|
||||
onInsert: async e => {
|
||||
const [id] = await Promise.all(
|
||||
e.items.filter(isTextDropItem).map(item => item.getText(DRAG_TYPE)),
|
||||
);
|
||||
reorderCategory({
|
||||
id: id as CategoryEntity['id'],
|
||||
targetId: e.target.key as CategoryEntity['id'],
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
},
|
||||
onReorder: e => {
|
||||
const [key] = e.keys;
|
||||
const categoryIdToMove = key as CategoryEntity['id'];
|
||||
const categoryToMove = categories.find(c => c.id === categoryIdToMove);
|
||||
|
||||
if (!categoryToMove) {
|
||||
throw new Error(
|
||||
`Internal error: category with ID ${categoryIdToMove} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!categoryToMove.group) {
|
||||
throw new Error(
|
||||
`Internal error: category ${categoryIdToMove} is not in a group and cannot be moved.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetCategoryId = e.target.key as CategoryEntity['id'];
|
||||
|
||||
if (e.target.dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: categoryToMove.group,
|
||||
targetId: targetCategoryId,
|
||||
}),
|
||||
);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
const targetCategoryIndex = categories.findIndex(
|
||||
c => c.id === targetCategoryId,
|
||||
);
|
||||
|
||||
if (targetCategoryIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category with ID ${targetCategoryId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categories[targetCategoryIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: categoryToMove.group,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
reorderCategory({
|
||||
id: key as CategoryEntity['id'],
|
||||
targetId: e.target.key as CategoryEntity['id'],
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -149,3 +118,76 @@ export function ExpenseCategoryList({
|
||||
</GridList>
|
||||
);
|
||||
}
|
||||
|
||||
function useReorderCategory() {
|
||||
const dispatch = useDispatch();
|
||||
const { list: categories } = useCategories();
|
||||
const reorderCategory = ({
|
||||
id,
|
||||
targetId,
|
||||
dropPosition,
|
||||
}: {
|
||||
id: CategoryEntity['id'];
|
||||
targetId: CategoryEntity['id'];
|
||||
dropPosition: 'on' | 'before' | 'after';
|
||||
}) => {
|
||||
const categoryToMove = categories.find(c => c.id === id);
|
||||
|
||||
if (!categoryToMove) {
|
||||
throw new Error(`Internal error: category with ID ${id} not found.`);
|
||||
}
|
||||
|
||||
if (!categoryToMove.group) {
|
||||
throw new Error(
|
||||
`Internal error: Failed to move category ${id} because it is not in a group.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetCategoryGroupId = categories.find(
|
||||
c => c.id === targetId,
|
||||
)?.group;
|
||||
|
||||
if (!targetCategoryGroupId) {
|
||||
throw new Error(
|
||||
`Internal error: Failed to move category ${id} because target category ${targetId} is not in a group.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: targetCategoryGroupId,
|
||||
targetId,
|
||||
}),
|
||||
);
|
||||
} else if (dropPosition === 'after') {
|
||||
const targetCategoryIndex = categories.findIndex(c => c.id === targetId);
|
||||
|
||||
if (targetCategoryIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category with ID ${targetId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categories[targetCategoryIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategory({
|
||||
id: categoryToMove.id,
|
||||
groupId: targetCategoryGroupId,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
reorderCategory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
} from './ExpenseGroupListItem';
|
||||
|
||||
import { moveCategoryGroup } from '@desktop-client/budget/budgetSlice';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
const DRAG_TYPE = 'mobile-expense-group-list/category-group-id';
|
||||
|
||||
type ExpenseGroupListProps = {
|
||||
categoryGroups: CategoryGroupEntity[];
|
||||
show3Columns: boolean;
|
||||
@@ -44,14 +47,14 @@ export function ExpenseGroupList({
|
||||
onToggleCollapse,
|
||||
}: ExpenseGroupListProps) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { reorderCategoryGroup } = useReorderCategoryGroup();
|
||||
const { dragAndDropHooks } = useDragAndDrop({
|
||||
getItems: keys =>
|
||||
[...keys].map(
|
||||
key =>
|
||||
({
|
||||
'text/plain': key as CategoryEntity['id'],
|
||||
[DRAG_TYPE]: key as CategoryGroupEntity['id'],
|
||||
}) as DragItem,
|
||||
),
|
||||
renderDropIndicator: target => {
|
||||
@@ -61,7 +64,7 @@ export function ExpenseGroupList({
|
||||
className={css({
|
||||
'&[data-drop-target]': {
|
||||
height: 4,
|
||||
backgroundColor: theme.tableBorderSeparator,
|
||||
backgroundColor: theme.tableBorderHover,
|
||||
opacity: 1,
|
||||
borderRadius: 4,
|
||||
},
|
||||
@@ -70,7 +73,7 @@ export function ExpenseGroupList({
|
||||
);
|
||||
},
|
||||
renderDragPreview: items => {
|
||||
const draggedGroupId = items[0]['text/plain'];
|
||||
const draggedGroupId = items[0][DRAG_TYPE];
|
||||
const group = categoryGroups.find(c => c.id === draggedGroupId);
|
||||
if (!group) {
|
||||
throw new Error(
|
||||
@@ -92,49 +95,11 @@ export function ExpenseGroupList({
|
||||
},
|
||||
onReorder: e => {
|
||||
const [key] = e.keys;
|
||||
const groupIdToMove = key as CategoryGroupEntity['id'];
|
||||
const groupToMove = categoryGroups.find(c => c.id === groupIdToMove);
|
||||
|
||||
if (!groupToMove) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${groupIdToMove} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const targetGroupId = e.target.key as CategoryEntity['id'];
|
||||
|
||||
if (e.target.dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
targetId: targetGroupId,
|
||||
}),
|
||||
);
|
||||
} else if (e.target.dropPosition === 'after') {
|
||||
const targetGroupIndex = categoryGroups.findIndex(
|
||||
c => c.id === targetGroupId,
|
||||
);
|
||||
|
||||
if (targetGroupIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${targetGroupId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
reorderCategoryGroup({
|
||||
id: key as CategoryGroupEntity['id'],
|
||||
targetId: e.target.key as CategoryGroupEntity['id'],
|
||||
dropPosition: e.target.dropPosition,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -174,3 +139,60 @@ export function ExpenseGroupList({
|
||||
</GridList>
|
||||
);
|
||||
}
|
||||
|
||||
function useReorderCategoryGroup() {
|
||||
const dispatch = useDispatch();
|
||||
const { list: categoryGroups } = useCategories();
|
||||
const reorderCategoryGroup = ({
|
||||
id,
|
||||
targetId,
|
||||
dropPosition,
|
||||
}: {
|
||||
id: CategoryGroupEntity['id'];
|
||||
targetId: CategoryGroupEntity['id'];
|
||||
dropPosition: 'on' | 'before' | 'after';
|
||||
}) => {
|
||||
const groupToMove = categoryGroups.find(c => c.id === id);
|
||||
|
||||
if (!groupToMove) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${id} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (dropPosition === 'before') {
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
targetId,
|
||||
}),
|
||||
);
|
||||
} else if (dropPosition === 'after') {
|
||||
const targetGroupIndex = categoryGroups.findIndex(c => c.id === targetId);
|
||||
|
||||
if (targetGroupIndex === -1) {
|
||||
throw new Error(
|
||||
`Internal error: category group with ID ${targetId} not found.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextToTargetCategory = categoryGroups[targetGroupIndex + 1];
|
||||
|
||||
dispatch(
|
||||
moveCategoryGroup({
|
||||
id: groupToMove.id,
|
||||
// Due to the way `moveCategory` works, we use the category next to the
|
||||
// actual target category here because `moveCategory` always shoves the
|
||||
// category *before* the target category.
|
||||
// On the other hand, using `null` as `targetId` moves the category
|
||||
// to the end of the list.
|
||||
targetId: nextToTargetCategory?.id || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
reorderCategoryGroup,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { isNonProductionEnvironment } from 'loot-core/shared/environment';
|
||||
|
||||
import {
|
||||
Modal,
|
||||
@@ -21,14 +18,6 @@ type ManageRulesModalProps = Extract<
|
||||
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const location = useLocation();
|
||||
if (isNonProductionEnvironment()) {
|
||||
if (location.pathname !== '/payees') {
|
||||
throw new Error(
|
||||
`Possibly invalid use of ManageRulesModal, add the current url \`${location.pathname}\` to the allowlist if you're confident the modal can never appear on top of the \`/rules\` page.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal name="manage-rules" isLoading={loading}>
|
||||
|
||||
@@ -38,10 +38,6 @@ const currentIntervalOptions = [
|
||||
description: t('Year to date'),
|
||||
disableInclude: true,
|
||||
},
|
||||
{
|
||||
description: t('Last month'),
|
||||
disableInclude: true,
|
||||
},
|
||||
{
|
||||
description: t('Last year'),
|
||||
disableInclude: true,
|
||||
|
||||
@@ -5,14 +5,22 @@ import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import type { FeatureFlag, SyncedPrefs } from 'loot-core/types/prefs';
|
||||
import type { FeatureFlag, ServerPrefs } from 'loot-core/types/prefs';
|
||||
|
||||
import { Setting } from './UI';
|
||||
|
||||
import { useAuth } from '@desktop-client/auth/AuthProvider';
|
||||
import { Permissions } from '@desktop-client/auth/types';
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
import { Checkbox } from '@desktop-client/components/forms';
|
||||
import {
|
||||
useLoginMethod,
|
||||
useMultiuserEnabled,
|
||||
} from '@desktop-client/components/ServerContext';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useServerPref } from '@desktop-client/hooks/useServerPref';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
|
||||
|
||||
type FeatureToggleProps = {
|
||||
flag: FeatureFlag;
|
||||
@@ -68,22 +76,42 @@ function FeatureToggle({
|
||||
);
|
||||
}
|
||||
|
||||
type GlobalFeatureToggleProps = {
|
||||
prefName: keyof SyncedPrefs;
|
||||
type ServerFeatureToggleProps = {
|
||||
prefName: keyof ServerPrefs;
|
||||
disableToggle?: boolean;
|
||||
error?: ReactNode;
|
||||
children: ReactNode;
|
||||
feedbackLink?: string;
|
||||
};
|
||||
|
||||
function GlobalFeatureToggle({
|
||||
function ServerFeatureToggle({
|
||||
prefName,
|
||||
disableToggle = false,
|
||||
feedbackLink,
|
||||
error,
|
||||
children,
|
||||
}: GlobalFeatureToggleProps) {
|
||||
const [enabled, setEnabled] = useSyncedPref(prefName);
|
||||
}: ServerFeatureToggleProps) {
|
||||
const [enabled, setEnabled] = useServerPref(prefName);
|
||||
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
const isUsingServer = syncServerStatus !== 'no-server';
|
||||
const isServerOffline = syncServerStatus === 'offline';
|
||||
const { hasPermission } = useAuth();
|
||||
const loginMethod = useLoginMethod();
|
||||
const multiuserEnabled = useMultiuserEnabled();
|
||||
|
||||
if (!isUsingServer || isServerOffline) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show to admins if OIDC is enabled, or to everyone if multi-user is not enabled
|
||||
const isAdmin = hasPermission(Permissions.ADMINISTRATOR);
|
||||
const oidcEnabled = loginMethod === 'openid';
|
||||
const shouldShow = (oidcEnabled && isAdmin) || !multiuserEnabled;
|
||||
|
||||
if (!shouldShow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<label style={{ display: 'flex' }}>
|
||||
@@ -131,6 +159,9 @@ export function ExperimentalFeatures() {
|
||||
(goalTemplatesEnabled &&
|
||||
localStorage.getItem('devEnableGoalTemplatesUI') === 'true');
|
||||
|
||||
const showServerPrefs =
|
||||
localStorage.getItem('devEnableServerPrefs') === 'true';
|
||||
|
||||
return (
|
||||
<Setting
|
||||
primaryAction={
|
||||
@@ -171,16 +202,15 @@ export function ExperimentalFeatures() {
|
||||
>
|
||||
<Trans>Crossover Report</Trans>
|
||||
</FeatureToggle>
|
||||
<FeatureToggle flag="forceReload">
|
||||
<Trans>Force reload app button</Trans>
|
||||
</FeatureToggle>
|
||||
<GlobalFeatureToggle
|
||||
prefName="plugins"
|
||||
disableToggle
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/5950"
|
||||
>
|
||||
<Trans>Client-Side plugins (soon)</Trans>
|
||||
</GlobalFeatureToggle>
|
||||
{showServerPrefs && (
|
||||
<ServerFeatureToggle
|
||||
prefName="flags.plugins"
|
||||
disableToggle
|
||||
feedbackLink="https://github.com/actualbudget/actual/issues/5950"
|
||||
>
|
||||
<Trans>Client-Side plugins (soon)</Trans>
|
||||
</ServerFeatureToggle>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ButtonWithLoading } from '@actual-app/components/button';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import { isElectron } from 'loot-core/shared/environment';
|
||||
|
||||
import { Setting } from './UI';
|
||||
|
||||
@@ -89,46 +88,3 @@ export function ResetSync() {
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForceReload() {
|
||||
const [reloading, setReloading] = useState(false);
|
||||
|
||||
async function onForceReload() {
|
||||
setReloading(true);
|
||||
try {
|
||||
if (!isElectron()) {
|
||||
const registration =
|
||||
await window.navigator.serviceWorker.getRegistration('/');
|
||||
if (registration) {
|
||||
await registration.update();
|
||||
if (registration.waiting) {
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
} finally {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Setting
|
||||
primaryAction={
|
||||
<ButtonWithLoading isLoading={reloading} onPress={onForceReload}>
|
||||
<Trans>Force reload app</Trans>
|
||||
</ButtonWithLoading>
|
||||
}
|
||||
>
|
||||
<Text>
|
||||
<Trans>
|
||||
<strong>Force reload app</strong> will clear the cached version of the
|
||||
app and load a fresh one. This is useful if you're experiencing
|
||||
issues with the app after an update or if cached files are causing
|
||||
problems. The app will reload automatically after clearing the cache.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Setting>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { ExportBudget } from './Export';
|
||||
import { FormatSettings } from './Format';
|
||||
import { LanguageSettings } from './LanguageSettings';
|
||||
import { RepairTransactions } from './RepairTransactions';
|
||||
import { ForceReload, ResetCache, ResetSync } from './Reset';
|
||||
import { ResetCache, ResetSync } from './Reset';
|
||||
import { ThemeSettings } from './Themes';
|
||||
import { AdvancedToggle, Setting } from './UI';
|
||||
|
||||
@@ -176,7 +176,6 @@ export function Settings() {
|
||||
const [budgetName] = useMetadataPref('budgetName');
|
||||
const dispatch = useDispatch();
|
||||
const isCurrencyExperimentalEnabled = useFeatureFlag('currency');
|
||||
const isForceReloadEnabled = useFeatureFlag('forceReload');
|
||||
const [_, setDefaultCurrencyCodePref] = useSyncedPref('defaultCurrencyCode');
|
||||
|
||||
const onCloseBudget = () => {
|
||||
@@ -253,7 +252,6 @@ export function Settings() {
|
||||
<ExportBudget />
|
||||
<AdvancedToggle>
|
||||
<AdvancedAbout />
|
||||
{isForceReloadEnabled && <ForceReload />}
|
||||
<ResetCache />
|
||||
<ResetSync />
|
||||
<RepairTransactions />
|
||||
|
||||
@@ -9,8 +9,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
formulaMode: false,
|
||||
currency: false,
|
||||
crossoverReport: false,
|
||||
plugins: false,
|
||||
forceReload: false,
|
||||
};
|
||||
|
||||
export function useFeatureFlag(name: FeatureFlag): boolean {
|
||||
|
||||
31
packages/desktop-client/src/hooks/useServerPref.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { type ServerPrefs } from 'loot-core/types/prefs';
|
||||
|
||||
import { saveServerPrefs } from '@desktop-client/prefs/prefsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
|
||||
type SetServerPrefAction<K extends keyof ServerPrefs> = (
|
||||
value: ServerPrefs[K],
|
||||
) => void;
|
||||
|
||||
export function useServerPref<K extends keyof ServerPrefs>(
|
||||
prefName: K,
|
||||
): [ServerPrefs[K], SetServerPrefAction<K>] {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const setPref = useCallback<SetServerPrefAction<K>>(
|
||||
value => {
|
||||
dispatch(
|
||||
saveServerPrefs({
|
||||
prefs: { [prefName]: value },
|
||||
}),
|
||||
);
|
||||
},
|
||||
[dispatch, prefName],
|
||||
);
|
||||
|
||||
const pref = useSelector(state => state.prefs.server[prefName]);
|
||||
|
||||
return [pref, setPref];
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { parseNumberFormat, setNumberFormat } from 'loot-core/shared/util';
|
||||
import {
|
||||
type GlobalPrefs,
|
||||
type MetadataPrefs,
|
||||
type ServerPrefs,
|
||||
type SyncedPrefs,
|
||||
} from 'loot-core/types/prefs';
|
||||
|
||||
@@ -12,6 +13,7 @@ import { resetApp } from '@desktop-client/app/appSlice';
|
||||
import { setI18NextLanguage } from '@desktop-client/i18n';
|
||||
import { closeModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { createAppAsyncThunk } from '@desktop-client/redux';
|
||||
import { getUserData } from '@desktop-client/users/usersSlice';
|
||||
|
||||
const sliceName = 'prefs';
|
||||
|
||||
@@ -19,12 +21,14 @@ type PrefsState = {
|
||||
local: MetadataPrefs;
|
||||
global: GlobalPrefs;
|
||||
synced: SyncedPrefs;
|
||||
server: ServerPrefs;
|
||||
};
|
||||
|
||||
const initialState: PrefsState = {
|
||||
local: {},
|
||||
global: {},
|
||||
synced: {},
|
||||
server: {},
|
||||
};
|
||||
|
||||
export const loadPrefs = createAppAsyncThunk(
|
||||
@@ -125,6 +129,23 @@ export const saveSyncedPrefs = createAppAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
type SaveServerPrefsPayload = {
|
||||
prefs: ServerPrefs;
|
||||
};
|
||||
|
||||
export const saveServerPrefs = createAppAsyncThunk(
|
||||
`${sliceName}/saveServerPrefs`,
|
||||
async ({ prefs }: SaveServerPrefsPayload, { dispatch }) => {
|
||||
const result = await send('save-server-prefs', { prefs });
|
||||
if (result && 'error' in result) {
|
||||
return { error: result.error };
|
||||
}
|
||||
|
||||
dispatch(mergeServerPrefs(prefs));
|
||||
return {};
|
||||
},
|
||||
);
|
||||
|
||||
type SetPrefsPayload = {
|
||||
local: MetadataPrefs;
|
||||
global: GlobalPrefs;
|
||||
@@ -134,6 +155,7 @@ type SetPrefsPayload = {
|
||||
type MergeLocalPrefsPayload = MetadataPrefs;
|
||||
type MergeGlobalPrefsPayload = GlobalPrefs;
|
||||
type MergeSyncedPrefsPayload = SyncedPrefs;
|
||||
type MergeServerPrefsPayload = ServerPrefs;
|
||||
|
||||
const prefsSlice = createSlice({
|
||||
name: sliceName,
|
||||
@@ -153,12 +175,34 @@ const prefsSlice = createSlice({
|
||||
mergeSyncedPrefs(state, action: PayloadAction<MergeSyncedPrefsPayload>) {
|
||||
state.synced = { ...state.synced, ...action.payload };
|
||||
},
|
||||
mergeServerPrefs(state, action: PayloadAction<MergeServerPrefsPayload>) {
|
||||
state.server = { ...state.server, ...action.payload };
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder.addCase(resetApp, state => ({
|
||||
...initialState,
|
||||
global: state.global || initialState.global,
|
||||
server: state.server || initialState.server,
|
||||
}));
|
||||
builder.addCase(getUserData.fulfilled, (state, action) => {
|
||||
if (!action.payload || typeof action.payload !== 'object') {
|
||||
return state;
|
||||
}
|
||||
|
||||
const { serverPrefs } = action.payload as {
|
||||
serverPrefs?: ServerPrefs | null;
|
||||
};
|
||||
|
||||
if (!serverPrefs) {
|
||||
return state;
|
||||
}
|
||||
|
||||
state.server = {
|
||||
...state.server,
|
||||
...serverPrefs,
|
||||
};
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -171,7 +215,13 @@ export const actions = {
|
||||
loadGlobalPrefs,
|
||||
saveGlobalPrefs,
|
||||
saveSyncedPrefs,
|
||||
saveServerPrefs,
|
||||
};
|
||||
|
||||
export const { mergeGlobalPrefs, mergeLocalPrefs, mergeSyncedPrefs, setPrefs } =
|
||||
actions;
|
||||
export const {
|
||||
mergeGlobalPrefs,
|
||||
mergeLocalPrefs,
|
||||
mergeServerPrefs,
|
||||
mergeSyncedPrefs,
|
||||
setPrefs,
|
||||
} = actions;
|
||||
|
||||
31
packages/docs/blog/2026-01-10-actual-budget-wrapped-2025.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Reflecting on Your 2025 Finances
|
||||
description: We created a small app to help you look back at your financial year — Actual Budget Wrapped 2025.
|
||||
date: 2026-01-10T10:00
|
||||
slug: actual-budget-wrapped-2025
|
||||
tags: [announcement]
|
||||
hide_table_of_contents: false
|
||||
authors: MatissJanis
|
||||
---
|
||||
|
||||
As we step into 2026, it's natural to look back at the year that just passed. While many apps send you a "year in review" at the end of December, we thought it would be fun to create something special for Actual Budget users — a way to reflect on your financial journey throughout 2025.
|
||||
|
||||
<!--truncate-->
|
||||
|
||||
## Introducing Actual Budget Wrapped 2025
|
||||
|
||||
We've built a small web app that helps you visualize your financial story from the past year. [Actual Budget Wrapped 2025](https://wrapped.actualbudget.org) takes your Actual Budget data and transforms it into a personalized financial recap.
|
||||
|
||||
Whether you want to see your biggest spending categories, track your income trends, or simply marvel at how many transactions you've managed — this tool gives you a different perspective on your financial data. Sometimes it's helpful to step back and see the bigger picture of where your money went throughout the year.
|
||||
|
||||
The app is designed to be simple and privacy-focused. Your data stays in your browser and is processed locally. We don't collect or store any of your financial information — it's just between you and your Actual Budget file.
|
||||
|
||||
## Why Reflect?
|
||||
|
||||
Looking back at your finances can be eye-opening. Maybe you'll discover patterns you weren't aware of, or perhaps you'll be pleasantly surprised by how much progress you've made toward your goals. Maybe you'll spot areas where you can improve, or celebrate wins you've forgotten about.
|
||||
|
||||
Personal finance isn't just about numbers on a screen — it's about the choices we make, the goals we set, and the progress we track along the way. Sometimes taking a step back helps us appreciate how far we've come.
|
||||
|
||||
So head over to [wrapped.actualbudget.org](https://wrapped.actualbudget.org) and take a few minutes to see your 2025 financial story. We hope it brings you some insights, or at the very least, a moment of reflection as you plan for the year ahead.
|
||||
|
||||
Here's to making 2026 another great year for your finances!
|
||||
@@ -174,6 +174,7 @@ async function getUser() {
|
||||
userId = null,
|
||||
displayName = null,
|
||||
loginMethod = null,
|
||||
prefs: serverPrefs,
|
||||
} = {},
|
||||
} = JSON.parse(res) || {};
|
||||
|
||||
@@ -195,6 +196,7 @@ async function getUser() {
|
||||
displayName,
|
||||
loginMethod,
|
||||
tokenExpired,
|
||||
serverPrefs,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.log(e);
|
||||
|
||||
@@ -20,7 +20,7 @@ type ScheduleTemplateTarget = {
|
||||
target: number;
|
||||
next_date_string: string;
|
||||
target_interval: number;
|
||||
target_frequency: string;
|
||||
target_frequency: string | undefined;
|
||||
num_months: number;
|
||||
completed: number;
|
||||
full: boolean;
|
||||
@@ -238,6 +238,8 @@ function getSinkingBaseContributionTotal(t: ScheduleTemplateTarget[]) {
|
||||
monthlyAmount = schedule.target / intervalMonths;
|
||||
break;
|
||||
default:
|
||||
// default to same math as monthly for now for non-reoccuring
|
||||
monthlyAmount = schedule.target / schedule.target_interval;
|
||||
break;
|
||||
}
|
||||
total += monthlyAmount;
|
||||
@@ -275,17 +277,21 @@ export async function runSchedule(
|
||||
|
||||
const isPayMonthOf = c =>
|
||||
c.full ||
|
||||
(c.target_frequency === 'monthly' &&
|
||||
((c.target_frequency === 'monthly' || !c.target_frequency) &&
|
||||
c.target_interval === 1 &&
|
||||
c.num_months === 0) ||
|
||||
(c.target_frequency === 'weekly' && c.target_interval <= 4) ||
|
||||
(c.target_frequency === 'daily' && c.target_interval <= 31) ||
|
||||
isReflectBudget();
|
||||
|
||||
const isSubMonthly = c =>
|
||||
c.target_frequency === 'weekly' || c.target_frequency === 'daily';
|
||||
|
||||
const t_payMonthOf = t.t.filter(isPayMonthOf);
|
||||
const t_sinking = t.t
|
||||
.filter(c => !isPayMonthOf(c))
|
||||
.sort((a, b) => a.next_date_string.localeCompare(b.next_date_string));
|
||||
const numSubMonthly = t.t.filter(isSubMonthly).length;
|
||||
const totalPayMonthOf = getPayMonthOfTotal(t_payMonthOf);
|
||||
const totalSinking = getSinkingTotal(t_sinking);
|
||||
const totalSinkingBaseContribution =
|
||||
@@ -303,7 +309,8 @@ export async function runSchedule(
|
||||
balance >= totalSinking + totalPayMonthOf ||
|
||||
(lastMonthGoal < totalSinking + totalPayMonthOf &&
|
||||
lastMonthGoal !== 0 &&
|
||||
balance >= lastMonthGoal)
|
||||
balance >= lastMonthGoal &&
|
||||
numSubMonthly > 0)
|
||||
) {
|
||||
to_budget += Math.round(totalPayMonthOf + totalSinkingBaseContribution);
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../types/prefs';
|
||||
import { createApp } from '../app';
|
||||
import * as db from '../db';
|
||||
import { PostError } from '../errors';
|
||||
import { getDefaultDocumentDir } from '../main';
|
||||
import { mutator } from '../mutators';
|
||||
import { post } from '../post';
|
||||
@@ -25,6 +26,7 @@ export type PreferencesHandlers = {
|
||||
'load-global-prefs': typeof loadGlobalPrefs;
|
||||
'save-prefs': typeof saveMetadataPrefs;
|
||||
'load-prefs': typeof loadMetadataPrefs;
|
||||
'save-server-prefs': typeof saveServerPrefs;
|
||||
};
|
||||
|
||||
export const app = createApp<PreferencesHandlers>();
|
||||
@@ -35,6 +37,7 @@ app.method('save-global-prefs', saveGlobalPrefs);
|
||||
app.method('load-global-prefs', loadGlobalPrefs);
|
||||
app.method('save-prefs', saveMetadataPrefs);
|
||||
app.method('load-prefs', loadMetadataPrefs);
|
||||
app.method('save-server-prefs', saveServerPrefs);
|
||||
|
||||
async function saveSyncedPrefs({
|
||||
id,
|
||||
@@ -198,3 +201,31 @@ async function saveMetadataPrefs(prefsToSet: MetadataPrefs) {
|
||||
async function loadMetadataPrefs(): Promise<MetadataPrefs> {
|
||||
return _getMetadataPrefs();
|
||||
}
|
||||
|
||||
async function saveServerPrefs({ prefs }: { prefs: Record<string, string> }) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) {
|
||||
return { error: 'not-logged-in' };
|
||||
}
|
||||
|
||||
try {
|
||||
const serverConfig = getServer();
|
||||
if (!serverConfig) {
|
||||
throw new Error('No sync server configured.');
|
||||
}
|
||||
await post(serverConfig.SIGNUP_SERVER + '/server-prefs', {
|
||||
token: userToken,
|
||||
prefs,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof PostError) {
|
||||
return {
|
||||
error: err.reason || 'network-failure',
|
||||
};
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ export type FeatureFlag =
|
||||
| 'actionTemplating'
|
||||
| 'formulaMode'
|
||||
| 'currency'
|
||||
| 'crossoverReport'
|
||||
| 'plugins'
|
||||
| 'forceReload';
|
||||
| 'crossoverReport';
|
||||
|
||||
/**
|
||||
* Cross-device preferences. These sync across devices when they are changed.
|
||||
@@ -23,7 +21,6 @@ export type SyncedPrefs = Partial<
|
||||
| 'currencySymbolPosition'
|
||||
| 'currencySpaceBetweenAmountAndSymbol'
|
||||
| 'defaultCurrencyCode'
|
||||
| 'plugins'
|
||||
| `show-account-${string}-net-worth-chart`
|
||||
| `side-nav.show-balance-history-${string}`
|
||||
| `show-balances-${string}`
|
||||
@@ -153,3 +150,7 @@ export type GlobalPrefsJson = Partial<{
|
||||
}>;
|
||||
|
||||
export type AuthMethods = 'password' | 'openid';
|
||||
|
||||
export type ServerPrefs = Partial<{
|
||||
'flags.plugins': 'true' | 'false';
|
||||
}>;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getAccountDb } from '../src/account-db';
|
||||
|
||||
export const up = async function () {
|
||||
const accountDb = getAccountDb();
|
||||
|
||||
accountDb.exec(`
|
||||
CREATE TABLE IF NOT EXISTS server_prefs
|
||||
(key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT);
|
||||
`);
|
||||
};
|
||||
|
||||
export const down = async function () {
|
||||
const accountDb = getAccountDb();
|
||||
|
||||
accountDb.exec(`
|
||||
DROP TABLE IF EXISTS server_prefs;
|
||||
`);
|
||||
};
|
||||
@@ -227,6 +227,33 @@ export function getUserPermission(userId) {
|
||||
return role;
|
||||
}
|
||||
|
||||
export function getServerPrefs() {
|
||||
const accountDb = getAccountDb();
|
||||
const rows = accountDb.all('SELECT key, value FROM server_prefs') || [];
|
||||
|
||||
return rows.reduce((prefs, row) => {
|
||||
prefs[row.key] = row.value;
|
||||
return prefs;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export function setServerPrefs(prefs) {
|
||||
const accountDb = getAccountDb();
|
||||
|
||||
if (!prefs) {
|
||||
return;
|
||||
}
|
||||
|
||||
accountDb.transaction(() => {
|
||||
Object.entries(prefs).forEach(([key, value]) => {
|
||||
accountDb.mutate(
|
||||
'INSERT INTO server_prefs (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
|
||||
[key, value],
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function clearExpiredSessions() {
|
||||
const clearThreshold = Math.floor(Date.now() / 1000) - 3600;
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
listLoginMethods,
|
||||
getUserInfo,
|
||||
getActiveLoginMethod,
|
||||
getServerPrefs,
|
||||
setServerPrefs,
|
||||
isAdmin,
|
||||
} from './account-db';
|
||||
import { isValidRedirectUrl, loginWithOpenIdSetup } from './accounts/openid';
|
||||
import { changePassword, loginWithPassword } from './accounts/password';
|
||||
@@ -128,6 +131,31 @@ app.post('/change-password', (req, res) => {
|
||||
res.send({ status: 'ok', data: {} });
|
||||
});
|
||||
|
||||
app.post('/server-prefs', (req, res) => {
|
||||
const session = validateSession(req, res);
|
||||
if (!session) return;
|
||||
|
||||
if (!isAdmin(session.user_id)) {
|
||||
res.status(403).send({
|
||||
status: 'error',
|
||||
reason: 'forbidden',
|
||||
details: 'permission-not-found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { prefs } = req.body || {};
|
||||
|
||||
if (!prefs || typeof prefs !== 'object') {
|
||||
res.status(400).send({ status: 'error', reason: 'invalid-prefs' });
|
||||
return;
|
||||
}
|
||||
|
||||
setServerPrefs(prefs);
|
||||
|
||||
res.send({ status: 'ok', data: {} });
|
||||
});
|
||||
|
||||
app.get('/validate', (req, res) => {
|
||||
const session = validateSession(req, res);
|
||||
if (session) {
|
||||
@@ -146,6 +174,7 @@ app.get('/validate', (req, res) => {
|
||||
userId: session?.user_id,
|
||||
displayName: user?.display_name,
|
||||
loginMethod: session?.auth_method,
|
||||
prefs: getServerPrefs(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
195
packages/sync-server/src/app-account.test.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import request from 'supertest';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getAccountDb, getServerPrefs } from './account-db';
|
||||
import { handlers as app } from './app-account';
|
||||
|
||||
const ADMIN_ROLE = 'ADMIN';
|
||||
const BASIC_ROLE = 'BASIC';
|
||||
|
||||
// Create user helper function
|
||||
const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
|
||||
getAccountDb().mutate(
|
||||
'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[userId, userName, `${userName} display`, enabled, owner, role],
|
||||
);
|
||||
};
|
||||
|
||||
const deleteUser = userId => {
|
||||
getAccountDb().mutate('DELETE FROM user_access WHERE user_id = ?', [userId]);
|
||||
getAccountDb().mutate('DELETE FROM users WHERE id = ?', [userId]);
|
||||
};
|
||||
|
||||
const createSession = (userId, sessionToken) => {
|
||||
getAccountDb().mutate(
|
||||
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
|
||||
[sessionToken, userId, Math.floor(Date.now() / 1000) + 60 * 60], // Expire in 1 hour (stored in seconds)
|
||||
);
|
||||
};
|
||||
|
||||
const generateSessionToken = () => `token-${uuidv4()}`;
|
||||
|
||||
const clearServerPrefs = () => {
|
||||
getAccountDb().mutate('DELETE FROM server_prefs');
|
||||
};
|
||||
|
||||
describe('/server-prefs', () => {
|
||||
describe('POST /server-prefs', () => {
|
||||
let adminUserId, basicUserId, adminSessionToken, basicSessionToken;
|
||||
|
||||
beforeEach(() => {
|
||||
adminUserId = uuidv4();
|
||||
basicUserId = uuidv4();
|
||||
adminSessionToken = generateSessionToken();
|
||||
basicSessionToken = generateSessionToken();
|
||||
|
||||
createUser(adminUserId, 'admin', ADMIN_ROLE);
|
||||
createUser(basicUserId, 'user', BASIC_ROLE);
|
||||
createSession(adminUserId, adminSessionToken);
|
||||
createSession(basicUserId, basicSessionToken);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
deleteUser(adminUserId);
|
||||
deleteUser(basicUserId);
|
||||
clearServerPrefs();
|
||||
});
|
||||
|
||||
it('should return 401 if no session token is provided', async () => {
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.send({
|
||||
prefs: { 'flags.plugins': 'true' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(401);
|
||||
expect(res.body).toHaveProperty('status', 'error');
|
||||
expect(res.body).toHaveProperty('reason', 'unauthorized');
|
||||
});
|
||||
|
||||
it('should return 403 if user is not an admin', async () => {
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', basicSessionToken)
|
||||
.send({
|
||||
prefs: { 'flags.plugins': 'true' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(403);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
reason: 'forbidden',
|
||||
details: 'permission-not-found',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if prefs is not an object', async () => {
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', adminSessionToken)
|
||||
.send({
|
||||
prefs: 'invalid',
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
reason: 'invalid-prefs',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if prefs is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', adminSessionToken)
|
||||
.send({});
|
||||
|
||||
expect(res.statusCode).toEqual(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
reason: 'invalid-prefs',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 if prefs is null', async () => {
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', adminSessionToken)
|
||||
.send({
|
||||
prefs: null,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toEqual(400);
|
||||
expect(res.body).toEqual({
|
||||
status: 'error',
|
||||
reason: 'invalid-prefs',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 and save server preferences for admin user', async () => {
|
||||
const prefs = { 'flags.plugins': 'true' };
|
||||
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', adminSessionToken)
|
||||
.send({ prefs });
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.body).toEqual({
|
||||
status: 'ok',
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Verify that preferences were saved
|
||||
const savedPrefs = getServerPrefs();
|
||||
expect(savedPrefs).toEqual(prefs);
|
||||
});
|
||||
|
||||
it('should update existing server preferences', async () => {
|
||||
// First, set initial preferences
|
||||
getAccountDb().mutate(
|
||||
'INSERT INTO server_prefs (key, value) VALUES (?, ?)',
|
||||
['flags.plugins', 'false'],
|
||||
);
|
||||
|
||||
// Update preferences
|
||||
const updatedPrefs = { 'flags.plugins': 'true' };
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', adminSessionToken)
|
||||
.send({ prefs: updatedPrefs });
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.body).toEqual({
|
||||
status: 'ok',
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Verify that preferences were updated
|
||||
const savedPrefs = getServerPrefs();
|
||||
expect(savedPrefs).toEqual(updatedPrefs);
|
||||
});
|
||||
|
||||
it('should save multiple server preferences', async () => {
|
||||
const prefs = {
|
||||
'flags.plugins': 'true',
|
||||
anotherKey: 'anotherValue',
|
||||
};
|
||||
|
||||
const res = await request(app)
|
||||
.post('/server-prefs')
|
||||
.set('x-actual-token', adminSessionToken)
|
||||
.send({ prefs });
|
||||
|
||||
expect(res.statusCode).toEqual(200);
|
||||
expect(res.body).toEqual({
|
||||
status: 'ok',
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Verify that all preferences were saved
|
||||
const savedPrefs = getServerPrefs();
|
||||
expect(savedPrefs).toEqual(prefs);
|
||||
});
|
||||
});
|
||||
});
|
||||
6
upcoming-release-notes/6234.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add server preferences for improved user settings consistency across devices.
|
||||
6
upcoming-release-notes/6577.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [csenel]
|
||||
---
|
||||
|
||||
Enable include current month option for last month
|
||||
6
upcoming-release-notes/6594.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [RMcGhee]
|
||||
---
|
||||
|
||||
Fix payee filter functionality to improve transaction filtering in the application.
|
||||
6
upcoming-release-notes/6608.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [dependabot]
|
||||
---
|
||||
|
||||
Bump react-router version from 7.9.6 to 7.12.0 for improved functionality and performance.
|
||||
6
upcoming-release-notes/6610.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [youngcw]
|
||||
---
|
||||
|
||||
Fix schedule template regressions where categories are being underbudgeted and to improve functionality and user experience.
|
||||
6
upcoming-release-notes/6625.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [RMcGhee]
|
||||
---
|
||||
|
||||
Remove url check that throws error in development
|
||||
6
upcoming-release-notes/6626.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Remove the force reload feature from the application settings.
|
||||
6
upcoming-release-notes/6634.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
[Mobile] Fix drag and drop across category groups
|
||||
10
yarn.lock
@@ -195,7 +195,7 @@ __metadata:
|
||||
react-markdown: "npm:^10.1.0"
|
||||
react-modal: "npm:3.16.3"
|
||||
react-redux: "npm:^9.2.0"
|
||||
react-router: "npm:7.9.6"
|
||||
react-router: "npm:7.12.0"
|
||||
react-simple-pull-to-refresh: "npm:^1.3.3"
|
||||
react-spring: "npm:10.0.0"
|
||||
react-swipeable: "npm:^7.0.2"
|
||||
@@ -23299,9 +23299,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-router@npm:7.9.6":
|
||||
version: 7.9.6
|
||||
resolution: "react-router@npm:7.9.6"
|
||||
"react-router@npm:7.12.0":
|
||||
version: 7.12.0
|
||||
resolution: "react-router@npm:7.12.0"
|
||||
dependencies:
|
||||
cookie: "npm:^1.0.1"
|
||||
set-cookie-parser: "npm:^2.6.0"
|
||||
@@ -23311,7 +23311,7 @@ __metadata:
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
checksum: 10/f34714b3701caf689c306631f5326a9fdab585799021c234aa3eee75bed6bfcea9250f0867e984e4e3c43c77d947c41bd47b70c0601d76c4290e03247fb7ac23
|
||||
checksum: 10/578324f792721200bd57a220c7931af692613943051c9bb0c6303613849ec9a2c2365a3a6afe1b3976c13edc8f71616bb9cfdb13c0ac501f239ad11a6884e3f8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||