Compare commits

...

42 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
59501edb56 Do not pass entire react query result to provider because it is not a stable reference 2026-03-03 17:17:04 +00:00
Joel Jeremy Marquez
d4d4cde3c7 Merge remote-tracking branch 'origin/master' into react-query-useSchedules 2026-03-03 16:09:47 +00:00
github-actions[bot]
acd1309752 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-23 19:25:21 +00:00
Joel Jeremy Marquez
95fd47d255 Fix tests 2026-02-23 18:46:02 +00:00
github-actions[bot]
24bd6dff45 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-23 18:39:21 +00:00
autofix-ci[bot]
3f1ba9ce88 [autofix.ci] apply automated fixes 2026-02-23 18:07:35 +00:00
Joel Jeremy Marquez
7ebf0c3693 Remove isLoading 2026-02-23 18:06:26 +00:00
Joel Jeremy Marquez
18f7eee81a Merge remote-tracking branch 'origin/master' into react-query-useSchedules 2026-02-23 17:42:52 +00:00
github-actions[bot]
e980c0cef7 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-19 19:54:43 +00:00
Joel Jeremy Marquez
b891f203d3 Set minHeight on variable hright rows and add borders 2026-02-19 19:28:34 +00:00
github-actions[bot]
a950ad9086 Update VRT screenshots
Auto-generated by VRT workflow

PR: #6766
2026-02-19 19:04:44 +00:00
autofix-ci[bot]
ac668664b4 [autofix.ci] apply automated fixes 2026-02-19 18:31:31 +00:00
Joel Jeremy Marquez
038b46df9a Merge branch 'master' into react-query-useSchedules 2026-02-19 10:28:36 -08:00
Joel Jeremy Marquez
dba2a2a1a8 Fix virtualized list messing up with the rendering 2026-02-19 18:26:36 +00:00
Joel Jeremy Marquez
ebd68a38ef Update test to wait for schedules list to load 2026-02-18 22:08:01 +00:00
Joel Jeremy Marquez
b126693968 Merge remote-tracking branch 'origin/master' into react-query-useSchedules 2026-02-18 20:08:26 +00:00
Joel Jeremy Marquez
9c4df27a8a Fix test using non-existent testid 2026-02-18 15:48:04 +00:00
Joel Jeremy Marquez
2aff508368 Use renderEmptyState 2026-02-18 00:12:52 +00:00
Joel Jeremy Marquez
5b63efaa10 Fix loading states 2026-02-17 22:59:15 +00:00
Joel Jeremy Marquez
673b5d241f Fix imports 2026-02-17 17:59:34 +00:00
autofix-ci[bot]
2223aa1c96 [autofix.ci] apply automated fixes 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
ecc95084f5 Add useSchedule and Fix loading states 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
089ad26419 Use isFetching instead of isPending 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
0cd55d5f36 Use isFetching instead of isPending 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
dcd0486f9a useSchedules and useScheduleStatus refetch on sync 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
ca7ea8a896 Fix typecheck error 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
a8f2cd6e36 Add sync and undo listeners for schedules to invalidate cache when updates are made 2026-02-17 17:44:56 +00:00
autofix-ci[bot]
8b2e114e99 [autofix.ci] apply automated fixes 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
27030adf40 Use isPending instead of isLoading 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
98a758c1b4 Use isPending instead of isFetching 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
d9a18d03ee Update type of SchedulesProvider 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
705b0f5d28 Fix typecheck error 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
cea282f3bc Coderabbit: Use StatusTypes renamed to ScheduleTransactionStatus) instead of ScheduleStatusLabel 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
c00db8ed95 Fix typecheck errors 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
b02386d654 Move useScheduleStatus to a separate file 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
27a43d6560 Add placeholderData to statuses query 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
9e317b0a71 Separate useSchedules and useSchedulesStatus because not all callers of useSchedules use the status. This saves us some unnecessary queries. 2026-02-17 17:44:56 +00:00
github-actions[bot]
11a4eb65a0 Add release notes for PR #6766 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
e3c178b89a Rename to ScheduleStatusMap and ScheduleStatusLabelMap to be clear that they are Maps 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
478aac731e Update imports 2026-02-17 17:44:56 +00:00
Joel Jeremy Marquez
953c9fcf09 Retrofit useSchedules to use react-query under the hood 2026-02-17 17:44:56 +00:00
Copilot
fc1811c0db Migrate setupTests.js to TypeScript with proper types (#6871)
* Initial plan

* Rename setupTests.js to setupTests.ts and add proper types

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Extract Size type to avoid duplication

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Add release note for setupTests TypeScript migration

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

* Rename release note file to match PR number 6871

Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: joel-jeremy <20313680+joel-jeremy@users.noreply.github.com>
2026-02-17 17:44:56 +00:00
82 changed files with 609 additions and 536 deletions

View File

@@ -1,18 +1,18 @@
import type { Locator, Page } from '@playwright/test';
const NO_PAYEES_FOUND_TEXT = 'No payees found.';
export class MobilePayeesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly payeesList: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
readonly noPayeesFoundText: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter payees…');
this.payeesList = page.getByRole('grid', { name: 'Payees' });
this.emptyMessage = page.getByText('No payees found.');
this.loadingIndicator = page.getByTestId('animated-loading');
this.noPayeesFoundText = this.payeesList.getByText(NO_PAYEES_FOUND_TEXT);
}
async waitFor(options?: {
@@ -47,7 +47,11 @@ export class MobilePayeesPage {
* Get all visible payee items
*/
getAllPayees() {
return this.payeesList.getByRole('gridcell');
// `GridList.renderEmptyState` still renders a row with "No payees found" text
// when no payees are present, so we need to filter that out to get the actual payee items.
return this.payeesList
.getByRole('row')
.filter({ hasNotText: NO_PAYEES_FOUND_TEXT });
}
/**
@@ -65,11 +69,4 @@ export class MobilePayeesPage {
const payees = this.getAllPayees();
return await payees.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -1,18 +1,21 @@
import type { Locator, Page } from '@playwright/test';
const NO_RULES_FOUND_TEXT =
'No rules found. Create your first rule to get started!';
export class MobileRulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly addButton: Locator;
readonly rulesList: Locator;
readonly emptyMessage: Locator;
readonly noRulesFoundText: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter rules…');
this.addButton = page.getByRole('button', { name: 'Add new rule' });
this.rulesList = page.getByRole('main');
this.emptyMessage = page.getByText('No rules found');
this.rulesList = page.getByRole('grid', { name: 'Rules' });
this.noRulesFoundText = this.rulesList.getByText(NO_RULES_FOUND_TEXT);
}
async waitFor(options?: {
@@ -47,7 +50,11 @@ export class MobileRulesPage {
* Get all visible rule items
*/
getAllRules() {
return this.page.getByRole('grid', { name: 'Rules' }).getByRole('row');
// `GridList.renderEmptyState` still renders a row with "No rules found" text
// when no rules are present, so we need to filter that out to get the actual rule items.
return this.rulesList
.getByRole('row')
.filter({ hasNotText: NO_RULES_FOUND_TEXT });
}
/**

View File

@@ -1,22 +1,23 @@
import type { Locator, Page } from '@playwright/test';
const NO_SCHEDULES_FOUND_TEXT =
'No schedules found. Create your first schedule to get started!';
export class MobileSchedulesPage {
readonly page: Page;
readonly searchBox: Locator;
readonly addButton: Locator;
readonly schedulesList: Locator;
readonly emptyMessage: Locator;
readonly loadingIndicator: Locator;
readonly noSchedulesFoundText: Locator;
constructor(page: Page) {
this.page = page;
this.searchBox = page.getByPlaceholder('Filter schedules…');
this.addButton = page.getByRole('button', { name: 'Add new schedule' });
this.schedulesList = page.getByRole('grid', { name: 'Schedules' });
this.emptyMessage = page.getByText(
'No schedules found. Create your first schedule to get started!',
this.noSchedulesFoundText = this.schedulesList.getByText(
NO_SCHEDULES_FOUND_TEXT,
);
this.loadingIndicator = page.getByTestId('animated-loading');
}
async waitFor(options?: {
@@ -51,7 +52,11 @@ export class MobileSchedulesPage {
* Get all visible schedule items
*/
getAllSchedules() {
return this.schedulesList.getByRole('gridcell');
// `GridList.renderEmptyState` still renders a row with "No schedules found" text
// when no schedules are present, so we need to filter that out to get the actual schedule items.
return this.schedulesList
.getByRole('row')
.filter({ hasNotText: NO_SCHEDULES_FOUND_TEXT });
}
/**
@@ -76,11 +81,4 @@ export class MobileSchedulesPage {
const schedules = this.getAllSchedules();
return await schedules.count();
}
/**
* Wait for loading to complete
*/
async waitForLoadingToComplete(timeout: number = 10000) {
await this.loadingIndicator.waitFor({ state: 'hidden', timeout });
}
}

View File

@@ -34,8 +34,6 @@ test.describe('Mobile Payees', () => {
});
test('checks the page visuals', async () => {
await payeesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(page.getByRole('heading', { name: 'Payees' })).toBeVisible();
@@ -63,8 +61,6 @@ test.describe('Mobile Payees', () => {
});
test('clicking on a payee opens payee edit page', async () => {
await payeesPage.waitForLoadingToComplete();
const payeeCount = await payeesPage.getPayeeCount();
expect(payeeCount).toBeGreaterThan(0);
@@ -89,8 +85,7 @@ test.describe('Mobile Payees', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
const emptyMessage = page.getByText('No payees found.');
await expect(emptyMessage).toBeVisible();
await expect(payeesPage.noPayeesFoundText).toBeVisible();
// Check that no payee items are visible
const payees = payeesPage.getAllPayees();
@@ -99,8 +94,6 @@ test.describe('Mobile Payees', () => {
});
test('search functionality works correctly', async () => {
await payeesPage.waitForLoadingToComplete();
// Test searching for a specific payee
await payeesPage.searchFor('Fast Internet');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -79,8 +79,7 @@ test.describe('Mobile Rules', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
const emptyMessage = page.getByText(/No rules found/);
await expect(emptyMessage).toBeVisible();
await expect(rulesPage.noRulesFoundText).toBeVisible();
// Check that no rule items are visible
const rules = rulesPage.getAllRules();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -27,6 +27,7 @@ test.describe('Mobile Schedules', () => {
// Navigate to schedules page and wait for it to load
schedulesPage = await navigation.goToSchedulesPage();
await schedulesPage.waitFor();
});
test.afterEach(async () => {
@@ -34,8 +35,6 @@ test.describe('Mobile Schedules', () => {
});
test('checks the page visuals', async () => {
await schedulesPage.waitForLoadingToComplete();
// Check that the header is present
await expect(
page.getByRole('heading', { name: 'Schedules' }),
@@ -60,14 +59,15 @@ test.describe('Mobile Schedules', () => {
await page.waitForTimeout(500);
// Check that empty message is shown
await expect(schedulesPage.emptyMessage).toBeVisible();
await expect(schedulesPage.noSchedulesFoundText).toBeVisible();
// Check that no schedule items are visible
const schedules = schedulesPage.getAllSchedules();
await expect(schedules).toHaveCount(0);
await expect(page).toMatchThemeScreenshots();
});
test('clicking on a schedule opens edit form', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for at least one schedule to be present
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();
@@ -89,8 +89,6 @@ test.describe('Mobile Schedules', () => {
});
test('searches and filters schedules', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for schedules to load
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();
@@ -118,8 +116,6 @@ test.describe('Mobile Schedules', () => {
});
test('displays schedule details correctly in list', async () => {
await schedulesPage.waitForLoadingToComplete();
// Wait for schedules to load
await expect(async () => {
const scheduleCount = await schedulesPage.getScheduleCount();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 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: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -130,8 +130,8 @@ export function ManageRules({
const [filter, setFilter] = useState('');
const dispatch = useDispatch();
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
const { data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const { data: { list: categories } = { list: [] } } = useCategories();
const { data: payees } = usePayees();

View File

@@ -59,7 +59,6 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFailedAccounts } from '@desktop-client/hooks/useFailedAccounts';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { SelectedProviderWithItems } from '@desktop-client/hooks/useSelected';
import type { Actions } from '@desktop-client/hooks/useSelected';
import {
@@ -83,6 +82,7 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
import { useDispatch, useSelector } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
import { schedulesViewQuery } from '@desktop-client/schedules';
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
@@ -1999,7 +1999,7 @@ export function Account() {
const savedFiters = useTransactionFilters();
const schedulesQuery = useMemo(
() => getSchedulesQuery(params.id),
() => schedulesViewQuery(params.id),
[params.id],
);

View File

@@ -12,7 +12,10 @@ import { useHover } from 'usehooks-ts';
import { q } from 'loot-core/shared/query';
import type { Query } from 'loot-core/shared/query';
import { getScheduledAmount } from 'loot-core/shared/schedules';
import { isPreviewId } from 'loot-core/shared/transactions';
import {
getScheduleFromPreviewId,
isPreviewId,
} from 'loot-core/shared/transactions';
import type { AccountEntity } from 'loot-core/types/models';
import { FinancialText } from '@desktop-client/components/FinancialText';
@@ -91,28 +94,29 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
let scheduleBalance = 0;
const { isLoading, schedules = [] } = useCachedSchedules();
const { data: schedules = [], isLoading } = useCachedSchedules();
if (isLoading) {
return null;
}
const previewIds = [...selectedItems]
const previewedScheduleIds = [...selectedItems]
.filter(id => isPreviewId(id))
.map(id => id.slice(8));
.map(id => getScheduleFromPreviewId(id));
let isExactBalance = true;
for (const s of schedules) {
if (previewIds.includes(s.id)) {
for (const schedule of schedules) {
if (previewedScheduleIds.includes(schedule.id)) {
// If a schedule is `between X and Y` then we calculate the average
if (s._amountOp === 'isbetween') {
if (schedule._amountOp === 'isbetween') {
isExactBalance = false;
}
if (!account || account.id === s._account) {
scheduleBalance += getScheduledAmount(s._amount);
if (!account || account.id === schedule._account) {
scheduleBalance += getScheduledAmount(schedule._amount);
} else {
scheduleBalance -= getScheduledAmount(s._amount);
scheduleBalance -= getScheduledAmount(schedule._amount);
}
}
}

View File

@@ -1,11 +1,10 @@
import React, { useRef, useState } from 'react';
import type { ReactNode } from 'react';
import { GridListItem } from 'react-aria-components';
import { composeRenderProps, GridListItem } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { animated, config, useSpring } from 'react-spring';
import { Button } from '@actual-app/components/button';
import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { useDrag } from '@use-gesture/react';
@@ -89,14 +88,11 @@ export function ActionableGridListItem<T extends object>({
{...props}
value={value}
textValue={textValue}
style={{
...styles.mobileListItem,
padding: 0,
backgroundColor: hasActions
? actionsBackgroundColor
: (styles.mobileListItem.backgroundColor ?? 'transparent'),
style={composeRenderProps(props.style, propStyle => ({
backgroundColor: hasActions ? actionsBackgroundColor : undefined,
overflow: 'hidden',
}}
...propStyle,
}))}
>
<animated.div
{...(hasActions ? bind() : {})}

View File

@@ -14,7 +14,6 @@ import { useAccountPreviewTransactions } from '@desktop-client/hooks/useAccountP
import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import {
@@ -25,6 +24,7 @@ import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSear
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function AccountTransactions({
@@ -33,7 +33,7 @@ export function AccountTransactions({
readonly account: AccountEntity;
}) {
const schedulesQuery = useMemo(
() => getSchedulesQuery(account.id),
() => schedulesViewQuery(account.id),
[account.id],
);

View File

@@ -11,16 +11,16 @@ import { SchedulesProvider } from '@desktop-client/hooks/useCachedSchedules';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePreviewTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function AllAccountTransactions() {
const schedulesQuery = useMemo(() => getSchedulesQuery(), []);
const schedulesQuery = useMemo(() => schedulesViewQuery(), []);
return (
<SchedulesProvider query={schedulesQuery}>

View File

@@ -12,16 +12,16 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useOffBudgetAccounts } from '@desktop-client/hooks/useOffBudgetAccounts';
import { usePreviewTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function OffBudgetAccountTransactions() {
const schedulesQuery = useMemo(() => getSchedulesQuery('offbudget'), []);
const schedulesQuery = useMemo(() => schedulesViewQuery('offbudget'), []);
return (
<SchedulesProvider query={schedulesQuery}>

View File

@@ -12,16 +12,16 @@ import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useOnBudgetAccounts } from '@desktop-client/hooks/useOnBudgetAccounts';
import { usePreviewTransactions } from '@desktop-client/hooks/usePreviewTransactions';
import { getSchedulesQuery } from '@desktop-client/hooks/useSchedules';
import { useTransactions } from '@desktop-client/hooks/useTransactions';
import { useTransactionsSearch } from '@desktop-client/hooks/useTransactionsSearch';
import { collapseModals, pushModal } from '@desktop-client/modals/modalsSlice';
import * as queries from '@desktop-client/queries';
import { useDispatch } from '@desktop-client/redux';
import { schedulesViewQuery } from '@desktop-client/schedules';
import * as bindings from '@desktop-client/spreadsheet/bindings';
export function OnBudgetAccountTransactions() {
const schedulesQuery = useMemo(() => getSchedulesQuery('onbudget'), []);
const schedulesQuery = useMemo(() => schedulesViewQuery('onbudget'), []);
return (
<SchedulesProvider query={schedulesQuery}>

View File

@@ -117,10 +117,6 @@ export function MobilePayeesPage() {
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search

View File

@@ -33,7 +33,7 @@ export function PayeesList({
}: PayeesListProps) {
const { t } = useTranslation();
if (isLoading && payees.length === 0) {
if (isLoading) {
return (
<View
style={{
@@ -48,29 +48,6 @@ export function PayeesList({
);
}
if (payees.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>No payees found.</Trans>
</Text>
</View>
);
}
return (
<View style={{ flex: 1 }}>
<Virtualizer layout={ListLayout}>
@@ -84,6 +61,26 @@ export function PayeesList({
overflow: 'auto',
}}
dependencies={[ruleCounts, isRuleCountsLoading]}
renderEmptyState={() => (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text
style={{
fontSize: 15,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>No payees found.</Trans>
</Text>
</View>
)}
>
{payee => (
<PayeesListItem

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { composeRenderProps } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
@@ -14,6 +15,8 @@ import type { WithRequired } from 'loot-core/types/util';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { PayeeRuleCountLabel } from '@desktop-client/components/payees/PayeeRuleCountLabel';
export const ROW_HEIGHT = 55;
type PayeesListItemProps = {
ruleCount: number;
isRuleCountLoading?: boolean;
@@ -27,6 +30,7 @@ export function PayeesListItem({
isRuleCountLoading,
onDelete,
onViewRules,
style,
...props
}: PayeesListItemProps) {
const { t } = useTranslation();
@@ -41,9 +45,22 @@ export function PayeesListItem({
value={payee}
textValue={label}
actionsWidth={200}
style={composeRenderProps(style, propStyle => ({
height: ROW_HEIGHT,
width: '100%',
borderBottom: `1px solid ${theme.tableBorder}`,
...propStyle,
}))}
actions={
!payee.transfer_acct && (
<View style={{ flexDirection: 'row', flex: 1 }}>
<View
style={{
flexDirection: 'row',
flex: 1,
height: ROW_HEIGHT,
width: '100%',
}}
>
<Button
variant="bare"
onPress={onViewRules}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useLocation, useParams } from 'react-router';
@@ -31,20 +31,14 @@ 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],
),
const { data: schedules = [], isSuccess } = useSchedules({
query: rule?.id
? q('schedules').filter({ rule: rule.id, completed: false }).select('*')
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule,
});
// Check if the current rule is linked to a schedule
const isLinkedToSchedule = schedules.length > 0;
const isLinkedToSchedule = isSuccess && schedules.length > 0;
// Load rule by ID if we're in edit mode
useEffect(() => {

View File

@@ -37,8 +37,8 @@ export function MobileRulesPage() {
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState('');
const { schedules = [] } = useSchedules({
query: useMemo(() => q('schedules').select('*'), []),
const { data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const { data: { list: categories } = { list: [] } } = useCategories();
const { data: payees = [] } = usePayees();
@@ -179,10 +179,6 @@ export function MobileRulesPage() {
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search

View File

@@ -1,5 +1,5 @@
import { GridList, ListLayout, Virtualizer } from 'react-aria-components';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
import { Text } from '@actual-app/components/text';
@@ -8,7 +8,7 @@ import { View } from '@actual-app/components/view';
import type { RuleEntity } from 'loot-core/types/models';
import { RulesListItem } from './RulesListItem';
import { ROW_HEIGHT, RulesListItem } from './RulesListItem';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
@@ -27,7 +27,7 @@ export function RulesList({
}: RulesListProps) {
const { t } = useTranslation();
if (isLoading && rules.length === 0) {
if (isLoading) {
return (
<View
style={{
@@ -42,36 +42,12 @@ export function RulesList({
);
}
if (rules.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
{t('No rules found. Create your first rule to get started!')}
</Text>
</View>
);
}
return (
<View style={{ flex: 1, overflow: 'auto' }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: 140,
padding: 0,
estimatedRowHeight: ROW_HEIGHT,
}}
>
<GridList
@@ -81,6 +57,28 @@ export function RulesList({
style={{
paddingBottom: MOBILE_NAV_HEIGHT,
}}
renderEmptyState={() => (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text
style={{
fontSize: 15,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>
No rules found. Create your first rule to get started!
</Trans>
</Text>
</View>
)}
>
{rule => (
<RulesListItem

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { composeRenderProps } from 'react-aria-components';
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';
import { View } from '@actual-app/components/view';
@@ -16,6 +16,8 @@ import { ActionExpression } from '@desktop-client/components/rules/ActionExpress
import { ConditionExpression } from '@desktop-client/components/rules/ConditionExpression';
import { groupActionsBySplitIndex } from '@desktop-client/util/ruleUtils';
export const ROW_HEIGHT = 150;
type RulesListItemProps = {
onDelete: () => void;
} & WithRequired<GridListItemProps<RuleEntity>, 'value'>;
@@ -37,13 +39,19 @@ export function RulesListItem({
id={rule.id}
value={rule}
textValue={t('Rule {{id}}', { id: rule.id })}
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
style={composeRenderProps(style, propStyle => ({
minHeight: ROW_HEIGHT,
width: '100%',
borderBottom: `1px solid ${theme.tableBorder}`,
...propStyle,
}))}
actions={
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
minHeight: ROW_HEIGHT,
width: '100%',
}}
>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { styles } from '@actual-app/components/styles';
@@ -24,6 +24,7 @@ import { useFormat } from '@desktop-client/hooks/useFormat';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { usePayees } from '@desktop-client/hooks/usePayees';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { useUndo } from '@desktop-client/hooks/useUndo';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -38,12 +39,13 @@ export function MobileSchedulesPage() {
const format = useFormat();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const {
isLoading: isSchedulesLoading,
schedules,
statuses,
} = useSchedules({ query: schedulesQuery });
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} } = {},
} = useScheduleStatus({ schedules });
const { data: payees = [] } = usePayees();
const { data: accounts = [] } = useAccounts();
@@ -69,7 +71,7 @@ export function MobileSchedulesPage() {
const dateStr = schedule.next_date
? monthUtilFormat(schedule.next_date, dateFormat)
: null;
const statusLabel = statuses.get(schedule.id);
const statusLabel = statusLookup[schedule.id];
return (
filterIncludes(schedule.name) ||
@@ -137,10 +139,6 @@ export function MobileSchedulesPage() {
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
padding: 10,
width: '100%',
borderBottomWidth: 2,
borderBottomStyle: 'solid',
borderBottomColor: theme.tableBorder,
}}
>
<Search
@@ -157,8 +155,8 @@ export function MobileSchedulesPage() {
</View>
<SchedulesList
schedules={filteredSchedules}
isLoading={isSchedulesLoading}
statuses={statuses}
isLoading={isSchedulesLoading || isScheduleStatusLoading}
statusLookup={statusLookup}
onSchedulePress={handleSchedulePress}
onScheduleDelete={handleScheduleDelete}
hasCompletedSchedules={hasCompletedSchedules}

View File

@@ -6,10 +6,10 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type { ScheduleStatusLookup } from 'loot-core/shared/schedules';
import type { ScheduleEntity } from 'loot-core/types/models';
import { SchedulesListItem } from './SchedulesListItem';
import { ROW_HEIGHT, SchedulesListItem } from './SchedulesListItem';
import { ActionableGridListItem } from '@desktop-client/components/mobile/ActionableGridListItem';
import { MOBILE_NAV_HEIGHT } from '@desktop-client/components/mobile/MobileNavTabs';
@@ -20,7 +20,7 @@ type SchedulesListEntry = ScheduleEntity | CompletedSchedulesItem;
type SchedulesListProps = {
schedules: readonly ScheduleEntity[];
isLoading: boolean;
statuses: Map<ScheduleEntity['id'], ScheduleStatusType>;
statusLookup: ScheduleStatusLookup;
onSchedulePress: (schedule: ScheduleEntity) => void;
onScheduleDelete: (schedule: ScheduleEntity) => void;
hasCompletedSchedules?: boolean;
@@ -31,7 +31,7 @@ type SchedulesListProps = {
export function SchedulesList({
schedules,
isLoading,
statuses,
statusLookup,
onSchedulePress,
onScheduleDelete,
hasCompletedSchedules = false,
@@ -44,9 +44,8 @@ export function SchedulesList({
const listItems: readonly SchedulesListEntry[] = shouldShowCompletedItem
? [...schedules, { id: 'show-completed' }]
: schedules;
const showCompletedLabel = t('Show completed schedules');
if (isLoading && listItems.length === 0) {
if (isLoading) {
return (
<View
style={{
@@ -61,54 +60,51 @@ export function SchedulesList({
);
}
if (listItems.length === 0) {
return (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 20,
}}
>
<Text
style={{
fontSize: 16,
color: theme.pageTextSubdued,
textAlign: 'center',
paddingLeft: 10,
paddingRight: 10,
}}
>
{t('No schedules found. Create your first schedule to get started!')}
</Text>
</View>
);
}
return (
<View style={{ flex: 1, overflow: 'auto' }}>
<Virtualizer
layout={ListLayout}
layoutOptions={{
estimatedRowHeight: 140,
padding: 0,
estimatedRowHeight: ROW_HEIGHT,
}}
>
<GridList
aria-label={t('Schedules')}
aria-busy={isLoading || undefined}
items={listItems}
dependencies={[statusLookup]}
style={{
paddingBottom: MOBILE_NAV_HEIGHT,
}}
renderEmptyState={() => (
<View
style={{
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.mobilePageBackground,
}}
>
<Text
style={{
fontSize: 15,
color: theme.pageTextSubdued,
textAlign: 'center',
}}
>
<Trans>
No schedules found. Create your first schedule to get started!
</Trans>
</Text>
</View>
)}
>
{item =>
!('completed' in item) ? (
<ActionableGridListItem
id="show-completed"
value={item}
textValue={showCompletedLabel}
textValue={t('Show completed schedules')}
onAction={onShowCompleted}
>
<View style={{ width: '100%', alignItems: 'center' }}>
@@ -125,7 +121,7 @@ export function SchedulesList({
) : (
<SchedulesListItem
value={item}
status={statuses.get(item.id) || 'scheduled'}
status={statusLookup[item.id] || 'scheduled'}
onAction={() => onSchedulePress(item)}
onDelete={() => onScheduleDelete(item)}
/>

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { composeRenderProps } from 'react-aria-components';
import type { GridListItemProps } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
@@ -10,8 +11,8 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { format as monthUtilFormat } from 'loot-core/shared/months';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import { getScheduledAmount } from 'loot-core/shared/schedules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import type { ScheduleEntity } from 'loot-core/types/models';
import type { WithRequired } from 'loot-core/types/util';
@@ -21,9 +22,11 @@ import { DisplayId } from '@desktop-client/components/util/DisplayId';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
export const ROW_HEIGHT = 110;
type SchedulesListItemProps = {
onDelete: () => void;
status: ScheduleStatusType;
status: ScheduleStatus;
} & WithRequired<GridListItemProps<ScheduleEntity>, 'value'>;
export function SchedulesListItem({
@@ -50,13 +53,19 @@ export function SchedulesListItem({
id={schedule.id}
value={schedule}
textValue={schedule.name || t('Unnamed schedule')}
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
style={composeRenderProps(style, propStyle => ({
minHeight: ROW_HEIGHT,
width: '100%',
borderBottom: `1px solid ${theme.tableBorder}`,
...propStyle,
}))}
actions={
<Button
variant="bare"
onPress={onDelete}
style={{
color: theme.errorText,
minHeight: ROW_HEIGHT,
width: '100%',
}}
>

View File

@@ -315,7 +315,7 @@ type PayeeIconsProps = {
function PayeeIcons({ transaction, transferAccount }: PayeeIconsProps) {
const { id, schedule: scheduleId } = transaction;
const { isLoading: isSchedulesLoading, schedules = [] } =
const { isLoading: isSchedulesLoading, data: schedules = [] } =
useCachedSchedules();
const isPreview = isPreviewId(id);
const schedule = schedules.find(s => s.id === scheduleId);

View File

@@ -189,9 +189,8 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
onLoaded: setAutomations,
});
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules } = useSchedules({
query: schedulesQuery,
const { data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const categories = useBudgetAutomationCategories();

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -9,7 +9,6 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { format } from 'loot-core/shared/months';
import { q } from 'loot-core/shared/query';
import {
extractScheduleConds,
scheduleIsRecurring,
@@ -22,7 +21,7 @@ import {
ModalTitle,
} from '@desktop-client/components/common/Modal';
import { useLocale } from '@desktop-client/hooks/useLocale';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useSchedule } from '@desktop-client/hooks/useSchedule';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
type ScheduledTransactionMenuModalProps = Extract<
@@ -44,19 +43,12 @@ export function ScheduledTransactionMenuModal({
borderTop: `1px solid ${theme.pillBorder}`,
};
const scheduleId = transactionId?.split('/')?.[1];
const schedulesQuery = useMemo(
() => q('schedules').filter({ id: scheduleId }).select('*'),
[scheduleId],
);
const { isLoading: isSchedulesLoading, schedules } = useSchedules({
query: schedulesQuery,
});
if (isSchedulesLoading) {
const { isPending: isSchedulesLoading, data: schedule } =
useSchedule(scheduleId);
if (isSchedulesLoading || !schedule) {
return null;
}
const schedule = schedules?.[0];
const { date: dateCond } = extractScheduleConds(schedule._conditions);
const canBeSkipped = scheduleIsRecurring(dateCond);
@@ -67,7 +59,7 @@ export function ScheduledTransactionMenuModal({
{({ state: { close } }) => (
<>
<ModalHeader
title={<ModalTitle title={schedule?.name || ''} shrinkOnOverflow />}
title={<ModalTitle title={schedule.name || ''} shrinkOnOverflow />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
@@ -81,7 +73,7 @@ export function ScheduledTransactionMenuModal({
<Trans>Scheduled date</Trans>
</Text>
<Text style={{ fontSize: 17, fontWeight: 700 }}>
{format(schedule?.next_date || '', 'MMMM dd, yyyy', locale)}
{format(schedule.next_date || '', 'MMMM dd, yyyy', locale)}
</Text>
</View>
<ScheduledTransactionMenu

View File

@@ -40,7 +40,7 @@ import {
parse,
unparse,
} from 'loot-core/shared/rules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import type {
NewRuleEntity,
RuleActionEntity,
@@ -58,7 +58,8 @@ import { GenericInput } from '@desktop-client/components/util/GenericInput';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useSchedule } from '@desktop-client/hooks/useSchedule';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import {
SelectedProvider,
useSelected,
@@ -365,27 +366,22 @@ function ScheduleDescription({ id }) {
const { isNarrowWidth } = useResponsive();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const format = useFormat();
const scheduleQuery = useMemo(
() => q('schedules').filter({ id }).select('*'),
[id],
);
const {
schedules,
statusLabels,
isLoading: isSchedulesLoading,
} = useSchedules({ query: scheduleQuery });
const { data: schedule, isLoading: isSchedulesLoading } = useSchedule(id);
if (isSchedulesLoading) {
const {
data: { statusLookup = {} } = {},
isLoading: isScheduleStatusLoading,
} = useScheduleStatus({ schedules: [schedule] });
if (isSchedulesLoading || isScheduleStatusLoading) {
return null;
}
const [schedule] = schedules;
if (schedule && schedules.length === 0) {
if (!schedule) {
return <View style={{ flex: 1 }}>{id}</View>;
}
const status = statusLabels.get(schedule.id) as ScheduleStatusType;
const status = statusLookup[schedule.id] as ScheduleStatus;
return (
<View

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
@@ -20,10 +20,11 @@ type ScheduleValueProps = {
export function ScheduleValue({ value }: ScheduleValueProps) {
const { t } = useTranslation();
const { data: byId = {} } = usePayeesById();
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { schedules = [], isLoading } = useSchedules({ query: schedulesQuery });
const { data: schedules = [], isLoading: isSchedulesLoading } = useSchedules({
query: q('schedules').select('*'),
});
if (isLoading) {
if (isSchedulesLoading) {
return (
<View aria-label={t('Loading...')} style={{ display: 'inline-flex' }}>
<AnimatedLoading width={10} height={10} />

View File

@@ -1,5 +1,5 @@
// @ts-strict-ignore
import React, { useMemo, useRef, useState } from 'react';
import React, { useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -10,6 +10,7 @@ import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { q } from 'loot-core/shared/query';
import type { ScheduleEntity } from 'loot-core/types/models';
import { ROW_HEIGHT, SchedulesTable } from './SchedulesTable';
@@ -20,6 +21,7 @@ import {
} from '@desktop-client/components/common/Modal';
import { Search } from '@desktop-client/components/common/Search';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -39,24 +41,23 @@ export function ScheduleLink({
const dispatch = useDispatch();
const [filter, setFilter] = useState(accountName || '');
const schedulesQuery = useMemo(
() => q('schedules').filter({ completed: false }).select('*'),
[],
);
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
query: q('schedules').filter({ completed: false }).select('*'),
});
const {
isLoading: isSchedulesLoading,
schedules,
statuses,
} = useSchedules({ query: schedulesQuery });
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} },
} = useScheduleStatus({ schedules });
const searchInput = useRef<HTMLInputElement | null>(null);
async function onSelect(scheduleId: string) {
async function onSelect(schedule: ScheduleEntity) {
if (ids?.length > 0) {
await send('transactions-batch-update', {
updated: ids.map(id => ({ id, schedule: scheduleId })),
updated: ids.map(id => ({ id, schedule: schedule.id })),
});
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
onScheduleLinked?.(schedule);
}
}
@@ -143,16 +144,16 @@ export function ScheduleLink({
}}
>
<SchedulesTable
isLoading={isSchedulesLoading}
isLoading={isSchedulesLoading || isScheduleStatusLoading}
allowCompleted={false}
filter={filter}
minimal
onSelect={id => {
void onSelect(id);
onSelect={schedule => {
void onSelect(schedule);
close();
}}
schedules={schedules}
statuses={statuses}
statusLookup={statusLookup}
style={null}
/>
</View>

View File

@@ -17,8 +17,8 @@ import { format as monthUtilFormat } from 'loot-core/shared/months';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { getScheduledAmount } from 'loot-core/shared/schedules';
import type {
ScheduleStatuses,
ScheduleStatusType,
ScheduleStatus,
ScheduleStatusLookup,
} from 'loot-core/shared/schedules';
import type { ScheduleEntity } from 'loot-core/types/models';
@@ -39,13 +39,14 @@ import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
import { useFormat } from '@desktop-client/hooks/useFormat';
import { usePayees } from '@desktop-client/hooks/usePayees';
type SchedulesTableProps = {
isLoading?: boolean;
schedules: readonly ScheduleEntity[];
statuses: ScheduleStatuses;
statusLookup: ScheduleStatusLookup;
filter: string;
allowCompleted: boolean;
onSelect: (id: ScheduleEntity['id']) => void;
onSelect: (schedule: ScheduleEntity) => void;
style: CSSProperties;
tableStyle?: CSSProperties;
} & (
@@ -81,7 +82,7 @@ function OverflowMenu({
onAction,
}: {
schedule: ScheduleEntity;
status: ScheduleStatusType;
status: ScheduleStatus;
onAction: SchedulesTableProps['onAction'];
}) {
const { t } = useTranslation();
@@ -203,15 +204,13 @@ function ScheduleRow({
onAction,
onSelect,
minimal,
statuses,
statusLookup,
dateFormat,
}: {
schedule: ScheduleEntity;
statusLookup: ScheduleStatusLookup;
dateFormat: string;
} & Pick<
SchedulesTableProps,
'onSelect' | 'onAction' | 'minimal' | 'statuses'
>) {
} & Pick<SchedulesTableProps, 'onSelect' | 'onAction' | 'minimal'>) {
const { t } = useTranslation();
const rowRef = useRef(null);
@@ -230,7 +229,7 @@ function ScheduleRow({
ref={rowRef}
height={ROW_HEIGHT}
inset={15}
onClick={() => onSelect(schedule.id)}
onClick={() => onSelect(schedule)}
style={{
cursor: 'pointer',
backgroundColor: theme.tableBackground,
@@ -251,7 +250,7 @@ function ScheduleRow({
>
<OverflowMenu
schedule={schedule}
status={statuses.get(schedule.id)}
status={statusLookup[schedule.id]}
onAction={(action, id) => {
onAction(action, id);
resetPosition();
@@ -284,7 +283,7 @@ function ScheduleRow({
: null}
</Field>
<Field width={120} name="status" style={{ alignItems: 'flex-start' }}>
<StatusBadge status={statuses.get(schedule.id)} />
<StatusBadge status={statusLookup[schedule.id]} />
</Field>
<ScheduleAmountCell amount={schedule._amount} op={schedule._amountOp} />
{!minimal && (
@@ -324,7 +323,7 @@ function ScheduleRow({
export function SchedulesTable({
isLoading,
schedules,
statuses,
statusLookup,
filter,
minimal,
allowCompleted,
@@ -371,11 +370,11 @@ export function SchedulesTable({
filterIncludes(payee && payee.name) ||
filterIncludes(account && account.name) ||
filterIncludes(amountStr) ||
filterIncludes(statuses.get(schedule.id)) ||
filterIncludes(statusLookup[schedule.id]) ||
filterIncludes(dateStr)
);
});
}, [payees, accounts, schedules, filter, statuses, format, dateFormat]);
}, [payees, accounts, schedules, filter, statusLookup, format, dateFormat]);
const items: readonly SchedulesTableItem[] = useMemo(() => {
const unCompletedSchedules = filteredSchedules.filter(s => !s.completed);
@@ -423,7 +422,7 @@ export function SchedulesTable({
return (
<ScheduleRow
schedule={item as ScheduleEntity}
{...{ statuses, dateFormat, onSelect, onAction, minimal }}
{...{ statusLookup, dateFormat, onSelect, onAction, minimal }}
/>
);
}

View File

@@ -15,12 +15,12 @@ import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { getStatusLabel } from 'loot-core/shared/schedules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import { titleFirst } from 'loot-core/shared/util';
// Consists of Schedule Statuses + Transaction statuses
export type StatusTypes =
| ScheduleStatusType
export type ScheduleTransactionStatus =
| ScheduleStatus
| 'cleared'
| 'pending'
| 'reconciled';
@@ -31,7 +31,7 @@ export const defaultStatusProps = {
Icon: SvgCheckCircleHollow,
};
export function getStatusProps(status: StatusTypes | null | undefined) {
export function getStatusProps(status?: ScheduleTransactionStatus | null) {
switch (status) {
case 'missed':
return {
@@ -92,7 +92,7 @@ export function getStatusProps(status: StatusTypes | null | undefined) {
}
}
export function StatusBadge({ status }: { status: ScheduleStatusType }) {
export function StatusBadge({ status }: { status: ScheduleTransactionStatus }) {
const { color, backgroundColor, Icon } = getStatusProps(status);
return (
<View

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
@@ -15,6 +15,7 @@ import type { ScheduleItemAction } from './SchedulesTable';
import { Search } from '@desktop-client/components/common/Search';
import { Page } from '@desktop-client/components/Page';
import { useSchedules } from '@desktop-client/hooks/useSchedules';
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { useDispatch } from '@desktop-client/redux';
@@ -25,9 +26,11 @@ export function Schedules() {
const [filter, setFilter] = useState('');
const onEdit = useCallback(
(id: ScheduleEntity['id']) => {
(schedule: ScheduleEntity) => {
dispatch(
pushModal({ modal: { name: 'schedule-edit', options: { id } } }),
pushModal({
modal: { name: 'schedule-edit', options: { id: schedule.id } },
}),
);
},
[dispatch],
@@ -78,12 +81,14 @@ export function Schedules() {
[],
);
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
query: q('schedules').select('*'),
});
const {
isLoading: isSchedulesLoading,
schedules,
statuses,
} = useSchedules({ query: schedulesQuery });
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} } = {},
} = useScheduleStatus({ schedules });
return (
<Page header={t('Schedules')}>
@@ -110,10 +115,10 @@ export function Schedules() {
</View>
<SchedulesTable
isLoading={isSchedulesLoading}
isLoading={isSchedulesLoading || isScheduleStatusLoading}
schedules={schedules}
filter={filter}
statuses={statuses}
statusLookup={statusLookup}
allowCompleted
onSelect={onEdit}
onAction={onAction}

View File

@@ -78,14 +78,10 @@ export function SelectedTransactionsButton({
.map(id => id.split('/')[1]);
}, [selectedIds]);
const scheduleQuery = useMemo(() => {
return q('schedules')
const { data: selectedSchedules = [] } = useSchedules({
query: q('schedules')
.filter({ id: { $oneof: scheduleIds } })
.select('*');
}, [scheduleIds]);
const { schedules: selectedSchedules } = useSchedules({
query: scheduleQuery,
.select('*'),
});
const types = useMemo(() => {

View File

@@ -67,14 +67,10 @@ export function TransactionMenu({
.map(id => id.split('/')[1]);
}, [selectedIds]);
const scheduleQuery = useMemo(() => {
return q('schedules')
const { data: selectedSchedules = [] } = useSchedules({
query: q('schedules')
.filter({ id: { $oneof: scheduleIds } })
.select('*');
}, [scheduleIds]);
const { schedules: selectedSchedules } = useSchedules({
query: scheduleQuery,
.select('*'),
});
const types = useMemo(() => {

View File

@@ -95,7 +95,7 @@ import { AccountAutocomplete } from '@desktop-client/components/autocomplete/Acc
import { CategoryAutocomplete } from '@desktop-client/components/autocomplete/CategoryAutocomplete';
import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete';
import { getStatusProps } from '@desktop-client/components/schedules/StatusBadge';
import type { StatusTypes } from '@desktop-client/components/schedules/StatusBadge';
import type { ScheduleTransactionStatus } from '@desktop-client/components/schedules/StatusBadge';
import { DateSelect } from '@desktop-client/components/select/DateSelect';
import {
Cell,
@@ -337,7 +337,7 @@ TransactionHeader.displayName = 'TransactionHeader';
type StatusCellProps = {
id: TransactionEntity['id'];
status?: StatusTypes | null;
status?: ScheduleTransactionStatus | null;
focused?: boolean;
selected?: boolean;
isChild?: boolean;
@@ -764,7 +764,7 @@ function PayeeIcons({
const { t } = useTranslation();
const scheduleId = transaction.schedule;
const { isLoading, schedules = [] } = useCachedSchedules();
const { isLoading, data: schedules = [] } = useCachedSchedules();
if (isLoading) {
return null;
@@ -1093,7 +1093,7 @@ const Transaction = memo(function Transaction({
_unmatched = false,
} = transaction;
const { schedules = [] } = useCachedSchedules();
const { data: schedules = [] } = useCachedSchedules();
const schedule = transaction.schedule
? schedules.find(s => s.id === transaction.schedule)
: null;
@@ -1692,7 +1692,7 @@ const Transaction = memo(function Transaction({
isPreview={isPreview}
status={
isPreview
? (previewStatus as StatusTypes)
? (previewStatus as ScheduleTransactionStatus)
: reconciled
? 'reconciled'
: cleared

View File

@@ -4,20 +4,23 @@ import type { PropsWithChildren } from 'react';
import { useSchedules } from './useSchedules';
import type { UseSchedulesProps, UseSchedulesResult } from './useSchedules';
type SchedulesContextValue = UseSchedulesResult;
type SchedulesContextValue = Pick<
UseSchedulesResult,
'data' | 'isLoading' | 'error'
>;
const SchedulesContext = createContext<SchedulesContextValue | undefined>(
undefined,
);
const SchedulesContext = createContext<SchedulesContextValue | null>(null);
type SchedulesProviderProps = PropsWithChildren<{
query?: UseSchedulesProps['query'];
}>;
type SchedulesProviderProps = PropsWithChildren<UseSchedulesProps>;
export function SchedulesProvider({
children,
...props
}: SchedulesProviderProps) {
const { isLoading, data, error } = useSchedules(props);
export function SchedulesProvider({ query, children }: SchedulesProviderProps) {
const data = useSchedules({ query });
return (
<SchedulesContext.Provider value={data}>
<SchedulesContext.Provider value={{ data, isLoading, error }}>
{children}
</SchedulesContext.Provider>
);

View File

@@ -6,7 +6,7 @@ import type { TFunction } from 'i18next';
import * as monthUtils from 'loot-core/shared/months';
import { getUpcomingDays } from 'loot-core/shared/schedules';
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
import type { ScheduleStatus } from 'loot-core/shared/schedules';
import type { CategoryEntity, ScheduleEntity } from 'loot-core/types/models';
import { useCategoryScheduleGoalTemplates } from './useCategoryScheduleGoalTemplates';
@@ -20,7 +20,7 @@ type UseCategoryScheduleGoalTemplateProps = {
type UseCategoryScheduleGoalTemplateResult = {
schedule: ScheduleEntity | null;
scheduleStatus: ScheduleStatusType | null;
scheduleStatus: ScheduleStatus | null;
isScheduleRecurring: boolean;
description: string;
};
@@ -42,15 +42,14 @@ export function useCategoryScheduleGoalTemplateIndicator({
'upcomingScheduledTransactionLength',
);
const upcomingDays = getUpcomingDays(upcomingScheduledTransactionLength);
const { schedules, statuses: scheduleStatuses } =
useCategoryScheduleGoalTemplates({
category,
});
const { schedules, statusLookup } = useCategoryScheduleGoalTemplates({
category,
});
return useMemo<UseCategoryScheduleGoalTemplateResult>(() => {
const schedulesToDisplay = schedules
.filter(schedule => {
const status = scheduleStatuses.get(schedule.id);
const status = statusLookup[schedule.id];
return status === 'upcoming' || status === 'due' || status === 'missed';
})
.filter(schedule => {
@@ -66,8 +65,8 @@ export function useCategoryScheduleGoalTemplateIndicator({
})
.sort((a, b) => {
// Display missed schedules first, then due, then upcoming.
const aStatus = scheduleStatuses.get(a.id);
const bStatus = scheduleStatuses.get(b.id);
const aStatus = statusLookup[a.id];
const bStatus = statusLookup[b.id];
if (aStatus === 'missed' && bStatus !== 'missed') return -1;
if (bStatus === 'missed' && aStatus !== 'missed') return 1;
if (aStatus === 'due' && bStatus !== 'due') return -1;
@@ -80,7 +79,7 @@ export function useCategoryScheduleGoalTemplateIndicator({
return getScheduleStatusDescription({
t,
schedule: s,
scheduleStatus: scheduleStatuses.get(s.id),
scheduleStatus: statusLookup[s.id],
locale,
});
})
@@ -88,7 +87,7 @@ export function useCategoryScheduleGoalTemplateIndicator({
const schedule = schedulesToDisplay[0] || null;
const scheduleStatus =
(schedule ? scheduleStatuses.get(schedule.id) : null) || null;
(schedule ? statusLookup[schedule.id] : null) || null;
return {
schedule,
@@ -98,7 +97,7 @@ export function useCategoryScheduleGoalTemplateIndicator({
),
description,
};
}, [locale, month, scheduleStatuses, schedules, t, upcomingDays]);
}, [locale, month, statusLookup, schedules, t, upcomingDays]);
}
function getScheduleStatusDescription({
@@ -109,7 +108,7 @@ function getScheduleStatusDescription({
}: {
t: TFunction;
schedule?: ScheduleEntity;
scheduleStatus?: ScheduleStatusType;
scheduleStatus?: ScheduleStatus;
locale?: Locale;
}) {
if (!schedule || !scheduleStatus) {

View File

@@ -1,11 +1,12 @@
import { useMemo } from 'react';
import type { ScheduleStatuses } from 'loot-core/shared/schedules';
import type { CategoryEntity, ScheduleEntity } from 'loot-core/types/models';
import { useCachedSchedules } from './useCachedSchedules';
import { useFeatureFlag } from './useFeatureFlag';
import type { ScheduleStatusLabels } from './useSchedules';
import { useScheduleStatus } from './useScheduleStatus';
import type { ScheduleStatusData } from '@desktop-client/schedules';
type ScheduleGoalDefinition = {
type: 'schedule';
@@ -16,28 +17,24 @@ type UseCategoryScheduleGoalTemplatesProps = {
category?: CategoryEntity | undefined;
};
type UseCategoryScheduleGoalTemplatesResult = {
schedules: ScheduleEntity[];
statuses: ScheduleStatuses;
statusLabels: ScheduleStatusLabels;
type UseCategoryScheduleGoalTemplatesResult = ScheduleStatusData & {
schedules: readonly ScheduleEntity[];
};
export function useCategoryScheduleGoalTemplates({
category,
}: UseCategoryScheduleGoalTemplatesProps): UseCategoryScheduleGoalTemplatesResult {
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
const {
schedules: allSchedules,
statuses: allStatuses,
statusLabels: allStatusLabels,
} = useCachedSchedules();
const { data: allSchedules = [] } = useCachedSchedules();
const { data: { statusLookup = {}, statusLabelLookup = {} } = {} } =
useScheduleStatus({ schedules: allSchedules });
return useMemo(() => {
if (!isGoalTemplatesEnabled || !category || !category.goal_def) {
return {
schedules: [],
statuses: new Map(),
statusLabels: new Map(),
statusLookup: {},
statusLabelLookup: {},
};
}
@@ -48,8 +45,8 @@ export function useCategoryScheduleGoalTemplates({
console.error('Failed to parse category goal_def:', e);
return {
schedules: [],
statuses: new Map(),
statusLabels: new Map(),
statusLookup: {},
statusLabelLookup: {},
};
}
@@ -60,8 +57,8 @@ export function useCategoryScheduleGoalTemplates({
if (!scheduleGoalDefinitions.length) {
return {
schedules: [],
statuses: new Map(),
statusLabels: new Map(),
statusLookup: {},
statusLabelLookup: {},
};
}
@@ -71,22 +68,22 @@ export function useCategoryScheduleGoalTemplates({
const scheduleIds = new Set(schedules.map(s => s.id));
const statuses = new Map(
[...allStatuses].filter(([id]) => scheduleIds.has(id)),
const filteredStatusLookup = Object.fromEntries(
Object.entries(statusLookup).filter(([id]) => scheduleIds.has(id)),
);
const statusLabels = new Map(
[...allStatusLabels].filter(([id]) => scheduleIds.has(id)),
const filteredStatusLabelLookup = Object.fromEntries(
Object.entries(statusLabelLookup).filter(([id]) => scheduleIds.has(id)),
);
return {
schedules,
statuses,
statusLabels,
statusLookup: filteredStatusLookup,
statusLabelLookup: filteredStatusLabelLookup,
};
}, [
allSchedules,
allStatusLabels,
allStatuses,
statusLabelLookup,
statusLookup,
category,
isGoalTemplatesEnabled,
]);

View File

@@ -7,6 +7,7 @@ import type { IntegerAmount } from 'loot-core/shared/util';
import type { ScheduleEntity, TransactionEntity } from 'loot-core/types/models';
import { useCachedSchedules } from './useCachedSchedules';
import { useScheduleStatus } from './useScheduleStatus';
import { useSyncedPref } from './useSyncedPref';
import { calculateRunningBalancesBottomUp } from './useTransactions';
@@ -42,10 +43,15 @@ export function usePreviewTransactions({
const {
isLoading: isSchedulesLoading,
error: scheduleQueryError,
schedules,
statuses,
data: schedules = [],
} = useCachedSchedules();
const [isLoading, setIsLoading] = useState(isSchedulesLoading);
const {
isLoading: isScheduleStatusLoading,
data: { statusLookup = {} } = {},
} = useScheduleStatus({ schedules });
const [isLoading, setIsLoading] = useState(
isSchedulesLoading || isScheduleStatusLoading,
);
const [error, setError] = useState<Error | undefined>(undefined);
const [runningBalances, setRunningBalances] = useState<
Map<TransactionEntity['id'], IntegerAmount>
@@ -60,17 +66,24 @@ export function usePreviewTransactions({
optionsRef.current = options;
const scheduleTransactions = useMemo(() => {
if (isSchedulesLoading) {
if (isSchedulesLoading || isScheduleStatusLoading) {
return [];
}
return computeSchedulePreviewTransactions(
schedules,
statuses,
statusLookup,
upcomingLength,
filter,
);
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
}, [
filter,
isSchedulesLoading,
isScheduleStatusLoading,
schedules,
statusLookup,
upcomingLength,
]);
useEffect(() => {
let isUnmounted = false;
@@ -95,7 +108,7 @@ export function usePreviewTransactions({
if (!isUnmounted) {
const withDefaults = newTrans.map(t => ({
...t,
category: t.schedule != null ? statuses.get(t.schedule) : undefined,
category: t.schedule != null ? statusLookup[t.schedule] : undefined,
schedule: t.schedule,
subtransactions: t.subtransactions?.map(
(st: TransactionEntity) => ({
@@ -137,13 +150,13 @@ export function usePreviewTransactions({
return () => {
isUnmounted = true;
};
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
}, [scheduleTransactions, schedules, statusLookup, upcomingLength]);
const returnError = error || scheduleQueryError;
return {
previewTransactions,
runningBalances,
isLoading: isLoading || isSchedulesLoading,
isLoading: isLoading || isSchedulesLoading || isScheduleStatusLoading,
...(returnError && { error: returnError }),
};
}

View File

@@ -0,0 +1,18 @@
import { useQuery } from '@tanstack/react-query';
import { q } from 'loot-core/shared/query';
import type { ScheduleEntity } from 'loot-core/types/models';
import { scheduleQueries } from '@desktop-client/schedules';
export function useSchedule(id?: ScheduleEntity['id'] | null) {
return useQuery({
...scheduleQueries.aql({
// Re-use the results of the get all schedules query
// since it's most likely already in the cache
query: q('schedules').select('*'),
}),
select: schedules => schedules.find(schedule => schedule.id === id),
enabled: !!id,
});
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useEffectEvent } from 'react';
import { useQuery } from '@tanstack/react-query';
import type { UseQueryResult } from '@tanstack/react-query';
import { listen } from 'loot-core/platform/client/connection';
import type { ScheduleEntity } from 'loot-core/types/models';
import type { ServerEvents } from 'loot-core/types/server-events';
import { useSyncedPref } from './useSyncedPref';
import { scheduleQueries } from '@desktop-client/schedules';
import type { ScheduleStatusData } from '@desktop-client/schedules';
type UseScheduleStatusProps = {
schedules: ScheduleEntity[];
};
type UseScheduleStatusResult = UseQueryResult<ScheduleStatusData>;
export function useScheduleStatus({
schedules,
}: UseScheduleStatusProps): UseScheduleStatusResult {
const [upcomingLength = '7'] = useSyncedPref(
'upcomingScheduledTransactionLength',
);
const queryResult = useQuery(
scheduleQueries.statuses({
schedules,
upcomingLength,
}),
);
const onSyncEvent = useEffectEvent((event: ServerEvents['sync-event']) => {
if (event.type === 'applied') {
const tables = event.tables;
if (tables.includes('schedules') || tables.includes('transactions')) {
void queryResult.refetch();
}
}
});
useEffect(() => listen('sync-event', onSyncEvent), []);
return queryResult;
}

View File

@@ -1,170 +1,35 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useEffectEvent } from 'react';
import { q } from 'loot-core/shared/query';
import { useQuery } from '@tanstack/react-query';
import type { UseQueryResult } from '@tanstack/react-query';
import { listen } from 'loot-core/platform/client/connection';
import type { Query } from 'loot-core/shared/query';
import {
getHasTransactionsQuery,
getStatus,
getStatusLabel,
} from 'loot-core/shared/schedules';
import type { ScheduleStatuses } from 'loot-core/shared/schedules';
import type {
AccountEntity,
ScheduleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import type { ScheduleEntity } from 'loot-core/types/models';
import type { ServerEvents } from 'loot-core/types/server-events';
import { useSyncedPref } from './useSyncedPref';
import { scheduleQueries } from '@desktop-client/schedules';
import { accountFilter } from '@desktop-client/queries';
import { liveQuery } from '@desktop-client/queries/liveQuery';
import type { LiveQuery } from '@desktop-client/queries/liveQuery';
export type ScheduleStatusLabelType = ReturnType<typeof getStatusLabel>;
export type ScheduleStatusLabels = Map<
ScheduleEntity['id'],
ScheduleStatusLabelType
>;
function loadStatuses(
schedules: readonly ScheduleEntity[],
onData: (data: ScheduleStatuses) => void,
onError: (error: Error) => void,
upcomingLength: string = '7',
) {
return liveQuery<TransactionEntity>(getHasTransactionsQuery(schedules), {
onData: data => {
const hasTrans = new Set(data.filter(Boolean).map(row => row.schedule));
const scheduleStatuses = new Map(
schedules.map(s => [
s.id,
getStatus(
s.next_date,
s.completed,
hasTrans.has(s.id),
upcomingLength,
),
]),
) as ScheduleStatuses;
onData?.(scheduleStatuses);
},
onError,
});
}
export type UseSchedulesProps = {
query?: Query;
};
type ScheduleData = {
schedules: readonly ScheduleEntity[];
statuses: ScheduleStatuses;
statusLabels: ScheduleStatusLabels;
};
export type UseSchedulesResult = ScheduleData & {
readonly isLoading: boolean;
readonly error?: Error;
};
export type UseSchedulesResult = UseQueryResult<ScheduleEntity[]>;
export function useSchedules({
query,
}: UseSchedulesProps = {}): UseSchedulesResult {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | undefined>(undefined);
const [data, setData] = useState<ScheduleData>({
schedules: [],
statuses: new Map(),
statusLabels: new Map(),
});
const [upcomingLength] = useSyncedPref('upcomingScheduledTransactionLength');
const queryResult = useQuery(scheduleQueries.aql({ query }));
const scheduleQueryRef = useRef<LiveQuery<ScheduleEntity> | null>(null);
const statusQueryRef = useRef<LiveQuery<TransactionEntity> | null>(null);
useEffect(() => {
let isUnmounted = false;
setError(undefined);
if (!query) {
// This usually means query is not yet set on this render cycle.
return;
}
function onError(error: Error) {
if (!isUnmounted) {
setError(error);
setIsLoading(false);
const onSyncEvent = useEffectEvent((event: ServerEvents['sync-event']) => {
if (event.type === 'applied') {
const tables = event.tables;
if (tables.includes('schedules') || tables.includes('rules')) {
void queryResult.refetch();
}
}
});
if (query.state.table !== 'schedules') {
onError(new Error('Query must be a schedules query.'));
return;
}
useEffect(() => listen('sync-event', onSyncEvent), []);
setIsLoading(true);
scheduleQueryRef.current = liveQuery<ScheduleEntity>(query, {
onData: async schedules => {
statusQueryRef.current = loadStatuses(
schedules,
(statuses: ScheduleStatuses) => {
if (!isUnmounted) {
setData({
schedules,
statuses,
statusLabels: new Map(
[...statuses.keys()].map(key => [
key,
getStatusLabel(statuses.get(key) || ''),
]),
),
});
setIsLoading(false);
}
},
onError,
upcomingLength,
);
},
onError,
});
return () => {
isUnmounted = true;
scheduleQueryRef.current?.unsubscribe();
statusQueryRef.current?.unsubscribe();
};
}, [query, upcomingLength]);
return {
isLoading,
error,
...data,
};
}
export function getSchedulesQuery(
view?: AccountEntity['id'] | 'onbudget' | 'offbudget' | 'uncategorized',
) {
const filterByAccount = accountFilter(view, '_account');
const filterByPayee = accountFilter(view, '_payee.transfer_acct');
let query = q('schedules')
.select('*')
.filter({
$and: [{ '_account.closed': false }],
});
if (view) {
if (view === 'uncategorized') {
query = query.filter({ next_date: null });
} else {
query = query.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return query.orderBy({ next_date: 'desc' });
return queryResult;
}

View File

@@ -0,0 +1 @@
export * from './queries';

View File

@@ -0,0 +1,124 @@
import { queryOptions } from '@tanstack/react-query';
import { q } from 'loot-core/shared/query';
import type { Query } from 'loot-core/shared/query';
import {
getHasTransactionsQuery,
getStatus,
getStatusLabel,
} from 'loot-core/shared/schedules';
import type {
ScheduleStatusLabelLookup,
ScheduleStatusLookup,
} from 'loot-core/shared/schedules';
import type {
AccountEntity,
ScheduleEntity,
TransactionEntity,
} from 'loot-core/types/models';
import { accountFilter } from '@desktop-client/queries';
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
export type ScheduleData = ScheduleStatusData & {
schedules: readonly ScheduleEntity[];
};
export type ScheduleStatusData = {
statusLookup: ScheduleStatusLookup;
statusLabelLookup: ScheduleStatusLabelLookup;
};
type AqlOptions = {
query?: Query;
};
export const scheduleQueries = {
all: () => ['schedules'],
aql: ({ query }: AqlOptions) =>
queryOptions<ScheduleEntity[]>({
queryKey: [...scheduleQueries.all(), 'aql', query],
queryFn: async () => {
if (!query) {
// Shouldn't happen because of the enabled flag, but needed to satisfy TS
throw new Error('No query provided.');
}
const { data: schedules }: { data: ScheduleEntity[] } =
await aqlQuery(query);
return schedules;
},
enabled: !!query,
placeholderData: [],
}),
statuses: ({
schedules,
upcomingLength,
}: {
schedules: readonly ScheduleEntity[];
upcomingLength: string;
}) =>
queryOptions<ScheduleStatusData>({
queryKey: [
...scheduleQueries.all(),
'statuses',
{ schedules, upcomingLength },
],
queryFn: async () => {
const { data: transactions }: { data: TransactionEntity[] } =
await aqlQuery(getHasTransactionsQuery(schedules));
const schedulesWithTransactions = new Set(
transactions.filter(Boolean).map(trans => trans.schedule),
);
const statusLookup: ScheduleStatusLookup = Object.fromEntries(
schedules.map(s => [
s.id,
getStatus(
s.next_date,
s.completed,
schedulesWithTransactions.has(s.id),
upcomingLength,
),
]),
);
const statusLabelLookup: ScheduleStatusLabelLookup = Object.fromEntries(
Object.keys(statusLookup).map(key => [
key,
getStatusLabel(statusLookup[key] || ''),
]),
);
return { statusLookup, statusLabelLookup };
},
enabled: schedules.length > 0,
placeholderData: { statusLookup: {}, statusLabelLookup: {} },
}),
};
export function schedulesViewQuery(
view?: AccountEntity['id'] | 'onbudget' | 'offbudget' | 'uncategorized',
) {
const filterByAccount = accountFilter(view, '_account');
const filterByPayee = accountFilter(view, '_payee.transfer_acct');
let query = q('schedules')
.select('*')
.filter({
$and: [{ '_account.closed': false }],
});
if (view) {
if (view === 'uncategorized') {
query = query.filter({ next_date: null });
} else {
query = query.filter({
$or: [filterByAccount, filterByPayee],
});
}
}
return query.orderBy({ next_date: 'desc' });
}

View File

@@ -11,7 +11,7 @@ import {
getStatus,
getUpcomingDays,
} from './schedules';
import type { ScheduleStatuses } from './schedules';
import type { ScheduleStatusLookup } from './schedules';
void i18next.init({
lng: 'en',
@@ -483,7 +483,7 @@ describe('schedules', () => {
],
});
const statuses: ScheduleStatuses = new Map([['sched-1', 'missed']]);
const statuses: ScheduleStatusLookup = { 'sched-1': 'missed' };
const result = computeSchedulePreviewTransactions(
[schedule],
statuses,
@@ -508,7 +508,7 @@ describe('schedules', () => {
],
});
const statuses: ScheduleStatuses = new Map([['sched-1', 'missed']]);
const statuses: ScheduleStatusLookup = { 'sched-1': 'missed' };
const result = computeSchedulePreviewTransactions(
[schedule],
statuses,
@@ -527,7 +527,7 @@ describe('schedules', () => {
_conditions: [{ field: 'date', op: 'is', value: '2017-01-03' }],
});
const statuses: ScheduleStatuses = new Map([['sched-1', 'upcoming']]);
const statuses: ScheduleStatusLookup = { 'sched-1': 'upcoming' };
const result = computeSchedulePreviewTransactions(
[schedule],
statuses,
@@ -551,7 +551,7 @@ describe('schedules', () => {
],
});
const statuses: ScheduleStatuses = new Map([['sched-1', 'paid']]);
const statuses: ScheduleStatusLookup = { 'sched-1': 'paid' };
const result = computeSchedulePreviewTransactions(
[schedule],
statuses,

View File

@@ -15,6 +15,8 @@ import { Condition } from '../server/rules';
import * as monthUtils from './months';
import { q } from './query';
export type ScheduleStatus = ReturnType<typeof getStatus>;
export function getStatus(
nextDate: string,
completed: boolean,
@@ -41,6 +43,8 @@ export function getStatus(
}
}
export type ScheduleStatusLabel = ReturnType<typeof getStatusLabel>;
export function getStatusLabel(status: string) {
switch (status) {
case 'completed':
@@ -60,7 +64,7 @@ export function getStatusLabel(status: string) {
}
}
export function getHasTransactionsQuery(schedules) {
export function getHasTransactionsQuery(schedules: readonly ScheduleEntity[]) {
const filters = schedules.map(schedule => {
const dateCond = schedule._conditions?.find(c => c.field === 'date');
return {
@@ -89,7 +93,7 @@ function makeNumberSuffix(num: number, locale: Locale) {
return monthUtils.format(new Date(2020, 0, num, 12), 'do', locale);
}
function prettyDayName(day) {
function prettyDayName(day: string) {
const days = {
SU: t('Sunday'),
MO: t('Monday'),
@@ -470,14 +474,17 @@ export function scheduleIsRecurring(dateCond: Condition | null) {
return value.type === 'recur';
}
export type ScheduleStatusType = ReturnType<typeof getStatus>;
export type ScheduleStatuses = Map<ScheduleEntity['id'], ScheduleStatusType>;
export type ScheduleStatusLookup = Record<ScheduleEntity['id'], ScheduleStatus>;
export type ScheduleStatusLabelLookup = Record<
ScheduleEntity['id'],
ScheduleStatusLabel
>;
export function isForPreview(
schedule: ScheduleEntity,
statuses: ScheduleStatuses,
statusMap: ScheduleStatusLookup,
) {
const status = statuses.get(schedule.id);
const status = statusMap[schedule.id];
return (
!schedule.completed &&
['due', 'upcoming', 'missed', 'paid'].includes(status!)
@@ -486,7 +493,7 @@ export function isForPreview(
export function computeSchedulePreviewTransactions(
schedules: readonly ScheduleEntity[],
statuses: ScheduleStatuses,
statuses: ScheduleStatusLookup,
upcomingLength?: string,
filter?: (schedule: ScheduleEntity) => boolean,
) {
@@ -508,7 +515,7 @@ export function computeSchedulePreviewTransactions(
schedule._conditions,
);
const status = statuses.get(schedule.id);
const status = statuses[schedule.id];
const isRecurring = scheduleIsRecurring(dateConditions);
const dates = [schedule.next_date];

View File

@@ -9,6 +9,10 @@ export function isTemporaryId(id: string) {
return id.indexOf('temp') !== -1;
}
export function getScheduleFromPreviewId(previewId: string) {
return previewId.slice('preview/'.length);
}
export function isPreviewId(id: string) {
return id.indexOf('preview/') !== -1;
}

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [joel-jeremy]
---
Refactor useSchedules to utilize react-query for improved data fetching and management.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Migrate setupTests.js to TypeScript with proper type definitions.