Compare commits
42 Commits
claude/act
...
react-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59501edb56 | ||
|
|
d4d4cde3c7 | ||
|
|
acd1309752 | ||
|
|
95fd47d255 | ||
|
|
24bd6dff45 | ||
|
|
3f1ba9ce88 | ||
|
|
7ebf0c3693 | ||
|
|
18f7eee81a | ||
|
|
e980c0cef7 | ||
|
|
b891f203d3 | ||
|
|
a950ad9086 | ||
|
|
ac668664b4 | ||
|
|
038b46df9a | ||
|
|
dba2a2a1a8 | ||
|
|
ebd68a38ef | ||
|
|
b126693968 | ||
|
|
9c4df27a8a | ||
|
|
2aff508368 | ||
|
|
5b63efaa10 | ||
|
|
673b5d241f | ||
|
|
2223aa1c96 | ||
|
|
ecc95084f5 | ||
|
|
089ad26419 | ||
|
|
0cd55d5f36 | ||
|
|
dcd0486f9a | ||
|
|
ca7ea8a896 | ||
|
|
a8f2cd6e36 | ||
|
|
8b2e114e99 | ||
|
|
27030adf40 | ||
|
|
98a758c1b4 | ||
|
|
d9a18d03ee | ||
|
|
705b0f5d28 | ||
|
|
cea282f3bc | ||
|
|
c00db8ed95 | ||
|
|
b02386d654 | ||
|
|
27a43d6560 | ||
|
|
9e317b0a71 | ||
|
|
11a4eb65a0 | ||
|
|
e3c178b89a | ||
|
|
478aac731e | ||
|
|
953c9fcf09 | ||
|
|
fc1811c0db |
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -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();
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
@@ -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();
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() : {})}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -117,10 +117,6 @@ export function MobilePayeesPage() {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
padding: 10,
|
||||
width: '100%',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
|
||||
18
packages/desktop-client/src/hooks/useSchedule.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
46
packages/desktop-client/src/hooks/useScheduleStatus.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1
packages/desktop-client/src/schedules/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './queries';
|
||||
124
packages/desktop-client/src/schedules/queries.ts
Normal 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' });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/6766.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Refactor useSchedules to utilize react-query for improved data fetching and management.
|
||||
6
upcoming-release-notes/6871.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Migrate setupTests.js to TypeScript with proper type definitions.
|
||||