Compare commits
3 Commits
react-quer
...
claude/deb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5373ca9012 | ||
|
|
42ce174fc9 | ||
|
|
512049376f |
@@ -20,13 +20,11 @@ jobs:
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
@@ -35,10 +33,6 @@ jobs:
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
- name: Pack the core package
|
||||
run: |
|
||||
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
|
||||
|
||||
- name: Build Server & Web
|
||||
run: yarn build:server
|
||||
|
||||
@@ -59,7 +53,6 @@ jobs:
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/loot-core/@actual-app/core.tgz
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
@@ -83,12 +76,6 @@ jobs:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
run: |
|
||||
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
|
||||
|
||||
11
.github/workflows/publish-npm-packages.yml
vendored
@@ -16,10 +16,6 @@ jobs:
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Pack the core package
|
||||
run: |
|
||||
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
|
||||
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
@@ -40,7 +36,6 @@ jobs:
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/loot-core/@actual-app/core.tgz
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
@@ -64,12 +59,6 @@ jobs:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
run: |
|
||||
npm publish loot-core/@actual-app/core.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
["parent", "subpath"],
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
|
||||
"elementNamePattern": ["loot-core/**"]
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
|
||||
@@ -101,18 +101,12 @@
|
||||
"typescript/no-var-requires": "error",
|
||||
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/require-array-sort-compare": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/no-for-in-array": "warn", // TODO: covert to error
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-misused-spread": "warn", // TODO: enable this
|
||||
"typescript/no-base-to-string": "warn", // TODO: enable this
|
||||
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
|
||||
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "error",
|
||||
|
||||
@@ -84,7 +84,7 @@ The core application logic that runs on any platform.
|
||||
|
||||
```bash
|
||||
# Run all loot-core tests
|
||||
yarn workspace @actual-app/core run test
|
||||
yarn workspace loot-core run test
|
||||
|
||||
# Or run tests across all packages using lage
|
||||
yarn test
|
||||
@@ -219,7 +219,7 @@ yarn test
|
||||
yarn test:debug
|
||||
|
||||
# Run tests for a specific package
|
||||
yarn workspace @actual-app/core run test
|
||||
yarn workspace loot-core run test
|
||||
```
|
||||
|
||||
**E2E Tests (Playwright)**
|
||||
@@ -625,7 +625,7 @@ Standard commands documented in `package.json` scripts and the Quick Start secti
|
||||
|
||||
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
|
||||
- `yarn test` (lage across all workspaces)
|
||||
- `yarn typecheck` (tsgo + lage typecheck)
|
||||
- `yarn typecheck` (tsc + lage typecheck)
|
||||
|
||||
### Testing and previewing the app
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ packages/desktop-client/bin/remove-untranslated-languages
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
echo "packages/desktop-client/build"
|
||||
|
||||
@@ -51,16 +51,16 @@ fi
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/core build:node
|
||||
yarn workspace loot-core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
# required for running the sync-server server
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
|
||||
# Emit loot-core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
yarn workspace loot-core exec tsc -p tsconfig.json
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
|
||||
11
package.json
@@ -25,16 +25,16 @@
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:docs": "yarn workspace docs start",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
@@ -53,11 +53,11 @@
|
||||
"vrt": "yarn workspace @actual-app/web run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace @actual-app/core rebuild",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -65,7 +65,6 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
|
||||
@@ -3,8 +3,8 @@ import type {
|
||||
RequestInit as FetchInit,
|
||||
} from 'node-fetch';
|
||||
|
||||
import { init as initLootCore } from '@actual-app/core/server/main';
|
||||
import type { InitConfig, lib } from '@actual-app/core/server/main';
|
||||
import { init as initLootCore } from 'loot-core/server/main';
|
||||
import type { InitConfig, lib } from 'loot-core/server/main';
|
||||
|
||||
import { validateNodeVersion } from './validateNodeVersion';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as path from 'path';
|
||||
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import type { RuleEntity } from '@actual-app/core/types/models';
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
|
||||
@@ -6,16 +6,16 @@ import type {
|
||||
APIPayeeEntity,
|
||||
APIScheduleEntity,
|
||||
APITagEntity,
|
||||
} from '@actual-app/core/server/api-models';
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
import type { Query } from '@actual-app/core/shared/query';
|
||||
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
|
||||
import type { Handlers } from '@actual-app/core/types/handlers';
|
||||
} from 'loot-core/server/api-models';
|
||||
import { lib } from 'loot-core/server/main';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import type { ImportTransactionsOpts } from 'loot-core/types/api-handlers';
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
export { q } from './app/query';
|
||||
|
||||
|
||||
@@ -10,26 +10,26 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"build": "yarn workspace loot-core exec tsc && vite build && node scripts/inline-loot-core-types.mjs",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b && tsc-strict"
|
||||
"typecheck": "tsc -b && tsc-strict"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"compare-versions": "^6.1.1",
|
||||
"loot-core": "workspace:^",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-peggy-loader": "^2.0.1",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
60
packages/api/scripts/inline-loot-core-types.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Post-build script: copies loot-core declaration tree into @types/loot-core
|
||||
* and rewrites index.d.ts to reference it so the published package is self-contained.
|
||||
* Run after vite build; requires loot-core declarations (yarn workspace loot-core exec tsc).
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = path.resolve(__dirname, '..');
|
||||
const typesDir = path.join(apiRoot, '@types');
|
||||
const indexDts = path.join(typesDir, 'index.d.ts');
|
||||
const lootCoreDeclRoot = path.resolve(apiRoot, '../loot-core/lib-dist/decl');
|
||||
const lootCoreDeclSrc = path.join(lootCoreDeclRoot, 'src');
|
||||
const lootCoreDeclTypings = path.join(lootCoreDeclRoot, 'typings');
|
||||
const lootCoreTypesDir = path.join(typesDir, 'loot-core');
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(indexDts)) {
|
||||
console.error('Missing @types/index.d.ts; run vite build first.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!fs.existsSync(lootCoreDeclSrc)) {
|
||||
console.error(
|
||||
'Missing loot-core declarations; run: yarn workspace loot-core exec tsc',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Remove existing loot-core output (dir or legacy single file)
|
||||
if (fs.existsSync(lootCoreTypesDir)) {
|
||||
fs.rmSync(lootCoreTypesDir, { recursive: true });
|
||||
}
|
||||
const legacyDts = path.join(typesDir, 'loot-core.d.ts');
|
||||
if (fs.existsSync(legacyDts)) {
|
||||
fs.rmSync(legacyDts);
|
||||
}
|
||||
|
||||
// Copy declaration tree: src (main exports) plus emitted typings so no declarations are dropped
|
||||
fs.cpSync(lootCoreDeclSrc, lootCoreTypesDir, { recursive: true });
|
||||
if (fs.existsSync(lootCoreDeclTypings)) {
|
||||
fs.cpSync(lootCoreDeclTypings, path.join(lootCoreTypesDir, 'typings'), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Rewrite index.d.ts: remove reference, point imports at local ./loot-core/
|
||||
let indexContent = fs.readFileSync(indexDts, 'utf8');
|
||||
indexContent = indexContent.replace(
|
||||
/\/\/\/ <reference path="\.\/loot-core\.d\.ts" \/>\n?/,
|
||||
'',
|
||||
);
|
||||
indexContent = indexContent
|
||||
.replace(/'loot-core\//g, "'./loot-core/")
|
||||
.replace(/"loot-core\//g, '"./loot-core/');
|
||||
fs.writeFileSync(indexDts, indexContent, 'utf8');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
import { lib } from 'loot-core/server/main';
|
||||
|
||||
export const amountToInteger = lib.amountToInteger;
|
||||
export const integerToAmount = lib.integerToAmount;
|
||||
|
||||
@@ -81,6 +81,12 @@ export default defineConfig({
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
|
||||
alias: [
|
||||
{
|
||||
find: /^@actual-app\/crdt(\/.*)?$/,
|
||||
replacement: path.resolve(__dirname, '../crdt/src') + '$1',
|
||||
},
|
||||
],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
|
||||
1
packages/ci-actions/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
dist/*
|
||||
@@ -5,12 +5,12 @@
|
||||
"scripts": {
|
||||
"tsx": "node --import=extensionless/register --experimental-strip-types",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"extensionless": "^2.0.6",
|
||||
"vitest": "^4.1.0"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"composite": true
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*", "bin/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -2,7 +2,7 @@ import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
@@ -32,9 +32,11 @@ const config: StorybookConfig = {
|
||||
const { mergeConfig } = await import('vite');
|
||||
|
||||
return mergeConfig(config, {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
// Telling Vite how to resolve path aliases
|
||||
plugins: [viteTsconfigPaths({ root: '../..' })],
|
||||
esbuild: {
|
||||
// Needed to handle JSX in .ts/.tsx files
|
||||
jsx: 'automatic',
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
|
||||
"start:storybook": "storybook dev -p 6006",
|
||||
"build:storybook": "storybook build",
|
||||
"typecheck": "tsgo -b"
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
@@ -54,14 +54,12 @@
|
||||
"@storybook/react-vite": "^10.2.7",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint-plugin-storybook": "^10.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.7",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.2",
|
||||
|
||||
@@ -16,8 +16,8 @@ import { View } from './View';
|
||||
|
||||
const MenuLine: unique symbol = Symbol('menu-line');
|
||||
const MenuLabel: unique symbol = Symbol('menu-label');
|
||||
Menu.line = MenuLine as typeof MenuLine;
|
||||
Menu.label = MenuLabel as typeof MenuLabel;
|
||||
Menu.line = MenuLine;
|
||||
Menu.label = MenuLabel;
|
||||
|
||||
type KeybindingProps = {
|
||||
keyName: ReactNode;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from 'path';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
@@ -24,7 +23,13 @@ export default defineConfig({
|
||||
maxWorkers: 2,
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@actual-app\/crdt(\/.*)?$/,
|
||||
replacement: path.resolve('../../../crdt/src$1'),
|
||||
},
|
||||
],
|
||||
extensions: resolveExtensions,
|
||||
},
|
||||
plugins: [react(), peggyLoader()],
|
||||
plugins: [peggyLoader()],
|
||||
});
|
||||
|
||||
@@ -8,23 +8,12 @@
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build:node": "tsgo",
|
||||
"build:node": "tsc",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
@@ -33,9 +22,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "3.15.12",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
"vitest": "^4.1.0"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 noPayeesFoundText: Locator;
|
||||
readonly emptyMessage: Locator;
|
||||
readonly loadingIndicator: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.searchBox = page.getByPlaceholder('Filter payees…');
|
||||
this.payeesList = page.getByRole('grid', { name: 'Payees' });
|
||||
this.noPayeesFoundText = this.payeesList.getByText(NO_PAYEES_FOUND_TEXT);
|
||||
this.emptyMessage = page.getByText('No payees found.');
|
||||
this.loadingIndicator = page.getByTestId('animated-loading');
|
||||
}
|
||||
|
||||
async waitFor(options?: {
|
||||
@@ -47,11 +47,7 @@ export class MobilePayeesPage {
|
||||
* Get all visible payee items
|
||||
*/
|
||||
getAllPayees() {
|
||||
// `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 });
|
||||
return this.payeesList.getByRole('gridcell');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,4 +65,11 @@ 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,21 +1,18 @@
|
||||
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 noRulesFoundText: Locator;
|
||||
readonly emptyMessage: 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('grid', { name: 'Rules' });
|
||||
this.noRulesFoundText = this.rulesList.getByText(NO_RULES_FOUND_TEXT);
|
||||
this.rulesList = page.getByRole('main');
|
||||
this.emptyMessage = page.getByText('No rules found');
|
||||
}
|
||||
|
||||
async waitFor(options?: {
|
||||
@@ -50,11 +47,7 @@ export class MobileRulesPage {
|
||||
* Get all visible rule items
|
||||
*/
|
||||
getAllRules() {
|
||||
// `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 });
|
||||
return this.page.getByRole('grid', { name: 'Rules' }).getByRole('row');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
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 noSchedulesFoundText: Locator;
|
||||
readonly emptyMessage: Locator;
|
||||
readonly loadingIndicator: 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.noSchedulesFoundText = this.schedulesList.getByText(
|
||||
NO_SCHEDULES_FOUND_TEXT,
|
||||
this.emptyMessage = page.getByText(
|
||||
'No schedules found. Create your first schedule to get started!',
|
||||
);
|
||||
this.loadingIndicator = page.getByTestId('animated-loading');
|
||||
}
|
||||
|
||||
async waitFor(options?: {
|
||||
@@ -52,11 +51,7 @@ export class MobileSchedulesPage {
|
||||
* Get all visible schedule items
|
||||
*/
|
||||
getAllSchedules() {
|
||||
// `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 });
|
||||
return this.schedulesList.getByRole('gridcell');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,4 +76,11 @@ 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,6 +34,8 @@ 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();
|
||||
|
||||
@@ -61,6 +63,8 @@ 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);
|
||||
|
||||
@@ -85,7 +89,8 @@ test.describe('Mobile Payees', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that empty message is shown
|
||||
await expect(payeesPage.noPayeesFoundText).toBeVisible();
|
||||
const emptyMessage = page.getByText('No payees found.');
|
||||
await expect(emptyMessage).toBeVisible();
|
||||
|
||||
// Check that no payee items are visible
|
||||
const payees = payeesPage.getAllPayees();
|
||||
@@ -94,6 +99,8 @@ 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: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 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: 25 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 |
@@ -79,7 +79,8 @@ test.describe('Mobile Rules', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that empty message is shown
|
||||
await expect(rulesPage.noRulesFoundText).toBeVisible();
|
||||
const emptyMessage = page.getByText(/No rules found/);
|
||||
await expect(emptyMessage).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: 13 KiB After Width: | Height: | Size: 14 KiB |
@@ -27,7 +27,6 @@ test.describe('Mobile Schedules', () => {
|
||||
|
||||
// Navigate to schedules page and wait for it to load
|
||||
schedulesPage = await navigation.goToSchedulesPage();
|
||||
await schedulesPage.waitFor();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
@@ -35,6 +34,8 @@ 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' }),
|
||||
@@ -59,15 +60,14 @@ test.describe('Mobile Schedules', () => {
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Check that empty message is shown
|
||||
await expect(schedulesPage.noSchedulesFoundText).toBeVisible();
|
||||
await expect(schedulesPage.emptyMessage).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,6 +89,8 @@ 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();
|
||||
@@ -116,6 +118,8 @@ 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: 14 KiB After Width: | Height: | Size: 15 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 |
@@ -16,12 +16,10 @@
|
||||
"e2e": "npx playwright test --browser=chromium",
|
||||
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
|
||||
"playwright": "playwright",
|
||||
"typecheck": "tsgo -b && tsc-strict"
|
||||
"typecheck": "tsc -b && tsc-strict"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actual-app/components": "workspace:*",
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
@@ -33,7 +31,6 @@
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@rolldown/plugin-babel": "~0.1.7",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "^0.5.18",
|
||||
@@ -48,11 +45,10 @@
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -65,6 +61,7 @@
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"lodash": "^4.17.23",
|
||||
"loot-core": "workspace:*",
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"pikaday": "1.8.2",
|
||||
@@ -94,12 +91,13 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"sass": "^1.97.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^8.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.1.0",
|
||||
"vitest": "^4.0.18",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +130,8 @@ export function ManageRules({
|
||||
const [filter, setFilter] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { data: schedules = [] } = useSchedules({
|
||||
query: q('schedules').select('*'),
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
const { data: { list: categories } = { list: [] } } = useCategories();
|
||||
const { data: payees } = usePayees();
|
||||
|
||||
@@ -17,7 +17,6 @@ import { CategoryMenuModal } from './modals/CategoryMenuModal';
|
||||
import { CloseAccountModal } from './modals/CloseAccountModal';
|
||||
import { ConfirmCategoryDeleteModal } from './modals/ConfirmCategoryDeleteModal';
|
||||
import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
|
||||
import { ConfirmPayeesMergeModal } from './modals/ConfirmPayeesMergeModal';
|
||||
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
|
||||
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
|
||||
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
|
||||
@@ -141,9 +140,6 @@ export function Modals() {
|
||||
case 'confirm-category-delete':
|
||||
return <ConfirmCategoryDeleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-payees-merge':
|
||||
return <ConfirmPayeesMergeModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-unlink-account':
|
||||
return <ConfirmUnlinkAccountModal key={key} {...modal.options} />;
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ 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 {
|
||||
@@ -82,7 +83,6 @@ 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;
|
||||
@@ -1661,11 +1661,6 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc);
|
||||
|
||||
// Always add sort_order as a final tiebreaker to maintain stable ordering
|
||||
// when transactions have the same values in the sorted column(s)
|
||||
this.currentQuery = this.currentQuery.orderBy({ sort_order: sortAscDesc });
|
||||
|
||||
this.updateQuery(this.currentQuery, isFiltered);
|
||||
};
|
||||
|
||||
@@ -1866,12 +1861,6 @@ class AccountInternal extends PureComponent<
|
||||
accountId === 'onbudget' ||
|
||||
accountId === 'uncategorized'
|
||||
}
|
||||
allowReorder={
|
||||
!!accountId &&
|
||||
accountId !== 'offbudget' &&
|
||||
accountId !== 'onbudget' &&
|
||||
accountId !== 'uncategorized'
|
||||
}
|
||||
isAdding={this.state.isAdding}
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
@@ -2010,7 +1999,7 @@ export function Account() {
|
||||
const savedFiters = useTransactionFilters();
|
||||
|
||||
const schedulesQuery = useMemo(
|
||||
() => schedulesViewQuery(params.id),
|
||||
() => getSchedulesQuery(params.id),
|
||||
[params.id],
|
||||
);
|
||||
|
||||
|
||||
@@ -12,10 +12,7 @@ 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 {
|
||||
getScheduleFromPreviewId,
|
||||
isPreviewId,
|
||||
} from 'loot-core/shared/transactions';
|
||||
import { isPreviewId } from 'loot-core/shared/transactions';
|
||||
import type { AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { FinancialText } from '@desktop-client/components/FinancialText';
|
||||
@@ -94,29 +91,28 @@ function SelectedBalance({ selectedItems, account }: SelectedBalanceProps) {
|
||||
|
||||
let scheduleBalance = 0;
|
||||
|
||||
const { data: schedules = [], isLoading } = useCachedSchedules();
|
||||
const { isLoading, schedules = [] } = useCachedSchedules();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previewedScheduleIds = [...selectedItems]
|
||||
const previewIds = [...selectedItems]
|
||||
.filter(id => isPreviewId(id))
|
||||
.map(id => getScheduleFromPreviewId(id));
|
||||
|
||||
.map(id => id.slice(8));
|
||||
let isExactBalance = true;
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (previewedScheduleIds.includes(schedule.id)) {
|
||||
for (const s of schedules) {
|
||||
if (previewIds.includes(s.id)) {
|
||||
// If a schedule is `between X and Y` then we calculate the average
|
||||
if (schedule._amountOp === 'isbetween') {
|
||||
if (s._amountOp === 'isbetween') {
|
||||
isExactBalance = false;
|
||||
}
|
||||
|
||||
if (!account || account.id === schedule._account) {
|
||||
scheduleBalance += getScheduledAmount(schedule._amount);
|
||||
if (!account || account.id === s._account) {
|
||||
scheduleBalance += getScheduledAmount(s._amount);
|
||||
} else {
|
||||
scheduleBalance -= getScheduledAmount(schedule._amount);
|
||||
scheduleBalance -= getScheduledAmount(s._amount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { composeRenderProps, GridListItem } from 'react-aria-components';
|
||||
import { 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';
|
||||
|
||||
@@ -88,11 +89,14 @@ export function ActionableGridListItem<T extends object>({
|
||||
{...props}
|
||||
value={value}
|
||||
textValue={textValue}
|
||||
style={composeRenderProps(props.style, propStyle => ({
|
||||
backgroundColor: hasActions ? actionsBackgroundColor : undefined,
|
||||
style={{
|
||||
...styles.mobileListItem,
|
||||
padding: 0,
|
||||
backgroundColor: hasActions
|
||||
? actionsBackgroundColor
|
||||
: (styles.mobileListItem.backgroundColor ?? 'transparent'),
|
||||
overflow: 'hidden',
|
||||
...propStyle,
|
||||
}))}
|
||||
}}
|
||||
>
|
||||
<animated.div
|
||||
{...(hasActions ? bind() : {})}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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 {
|
||||
@@ -24,7 +25,6 @@ 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(
|
||||
() => schedulesViewQuery(account.id),
|
||||
() => getSchedulesQuery(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(() => schedulesViewQuery(), []);
|
||||
const schedulesQuery = useMemo(() => getSchedulesQuery(), []);
|
||||
|
||||
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(() => schedulesViewQuery('offbudget'), []);
|
||||
const schedulesQuery = useMemo(() => getSchedulesQuery('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(() => schedulesViewQuery('onbudget'), []);
|
||||
const schedulesQuery = useMemo(() => getSchedulesQuery('onbudget'), []);
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
|
||||
@@ -117,6 +117,10 @@ 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) {
|
||||
if (isLoading && payees.length === 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -48,6 +48,29 @@ 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}>
|
||||
@@ -61,26 +84,6 @@ 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,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { composeRenderProps } from 'react-aria-components';
|
||||
import type { GridListItemProps } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -15,8 +14,6 @@ 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;
|
||||
@@ -30,7 +27,6 @@ export function PayeesListItem({
|
||||
isRuleCountLoading,
|
||||
onDelete,
|
||||
onViewRules,
|
||||
style,
|
||||
...props
|
||||
}: PayeesListItemProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -45,22 +41,9 @@ 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,
|
||||
height: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', flex: 1 }}>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onViewRules}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useLocation, useParams } from 'react-router';
|
||||
|
||||
@@ -31,14 +31,20 @@ export function MobileRuleEditPage() {
|
||||
const [rule, setRule] = useState<RuleEntity | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
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,
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(
|
||||
() =>
|
||||
rule?.id
|
||||
? q('schedules')
|
||||
.filter({ rule: rule.id, completed: false })
|
||||
.select('*')
|
||||
: q('schedules').filter({ id: null }).select('*'), // Return empty result when no rule
|
||||
[rule?.id],
|
||||
),
|
||||
});
|
||||
|
||||
// Check if the current rule is linked to a schedule
|
||||
const isLinkedToSchedule = isSuccess && schedules.length > 0;
|
||||
const isLinkedToSchedule = 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 { data: schedules = [] } = useSchedules({
|
||||
query: q('schedules').select('*'),
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
const { data: { list: categories } = { list: [] } } = useCategories();
|
||||
const { data: payees = [] } = usePayees();
|
||||
@@ -179,6 +179,10 @@ 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 { Trans, useTranslation } from 'react-i18next';
|
||||
import { 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 { ROW_HEIGHT, RulesListItem } from './RulesListItem';
|
||||
import { 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) {
|
||||
if (isLoading && rules.length === 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -42,12 +42,36 @@ 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: ROW_HEIGHT,
|
||||
estimatedRowHeight: 140,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<GridList
|
||||
@@ -57,28 +81,6 @@ 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,8 +16,6 @@ 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'>;
|
||||
@@ -39,19 +37,13 @@ export function RulesListItem({
|
||||
id={rule.id}
|
||||
value={rule}
|
||||
textValue={t('Rule {{id}}', { id: rule.id })}
|
||||
style={composeRenderProps(style, propStyle => ({
|
||||
minHeight: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
...propStyle,
|
||||
}))}
|
||||
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
|
||||
actions={
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onDelete}
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
minHeight: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
@@ -24,7 +24,6 @@ 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';
|
||||
@@ -39,13 +38,12 @@ export function MobileSchedulesPage() {
|
||||
const format = useFormat();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
|
||||
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
|
||||
query: q('schedules').select('*'),
|
||||
});
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
const {
|
||||
isLoading: isScheduleStatusLoading,
|
||||
data: { statusLookup = {} } = {},
|
||||
} = useScheduleStatus({ schedules });
|
||||
isLoading: isSchedulesLoading,
|
||||
schedules,
|
||||
statuses,
|
||||
} = useSchedules({ query: schedulesQuery });
|
||||
|
||||
const { data: payees = [] } = usePayees();
|
||||
const { data: accounts = [] } = useAccounts();
|
||||
@@ -71,7 +69,7 @@ export function MobileSchedulesPage() {
|
||||
const dateStr = schedule.next_date
|
||||
? monthUtilFormat(schedule.next_date, dateFormat)
|
||||
: null;
|
||||
const statusLabel = statusLookup[schedule.id];
|
||||
const statusLabel = statuses.get(schedule.id);
|
||||
|
||||
return (
|
||||
filterIncludes(schedule.name) ||
|
||||
@@ -139,6 +137,10 @@ export function MobileSchedulesPage() {
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.mobilePageBackground,
|
||||
padding: 10,
|
||||
width: '100%',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomStyle: 'solid',
|
||||
borderBottomColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<Search
|
||||
@@ -155,8 +157,8 @@ export function MobileSchedulesPage() {
|
||||
</View>
|
||||
<SchedulesList
|
||||
schedules={filteredSchedules}
|
||||
isLoading={isSchedulesLoading || isScheduleStatusLoading}
|
||||
statusLookup={statusLookup}
|
||||
isLoading={isSchedulesLoading}
|
||||
statuses={statuses}
|
||||
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 { ScheduleStatusLookup } from 'loot-core/shared/schedules';
|
||||
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
|
||||
import type { ScheduleEntity } from 'loot-core/types/models';
|
||||
|
||||
import { ROW_HEIGHT, SchedulesListItem } from './SchedulesListItem';
|
||||
import { 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;
|
||||
statusLookup: ScheduleStatusLookup;
|
||||
statuses: Map<ScheduleEntity['id'], ScheduleStatusType>;
|
||||
onSchedulePress: (schedule: ScheduleEntity) => void;
|
||||
onScheduleDelete: (schedule: ScheduleEntity) => void;
|
||||
hasCompletedSchedules?: boolean;
|
||||
@@ -31,7 +31,7 @@ type SchedulesListProps = {
|
||||
export function SchedulesList({
|
||||
schedules,
|
||||
isLoading,
|
||||
statusLookup,
|
||||
statuses,
|
||||
onSchedulePress,
|
||||
onScheduleDelete,
|
||||
hasCompletedSchedules = false,
|
||||
@@ -44,8 +44,9 @@ export function SchedulesList({
|
||||
const listItems: readonly SchedulesListEntry[] = shouldShowCompletedItem
|
||||
? [...schedules, { id: 'show-completed' }]
|
||||
: schedules;
|
||||
const showCompletedLabel = t('Show completed schedules');
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading && listItems.length === 0) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -60,51 +61,54 @@ 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: ROW_HEIGHT,
|
||||
estimatedRowHeight: 140,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<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={t('Show completed schedules')}
|
||||
textValue={showCompletedLabel}
|
||||
onAction={onShowCompleted}
|
||||
>
|
||||
<View style={{ width: '100%', alignItems: 'center' }}>
|
||||
@@ -121,7 +125,7 @@ export function SchedulesList({
|
||||
) : (
|
||||
<SchedulesListItem
|
||||
value={item}
|
||||
status={statusLookup[item.id] || 'scheduled'}
|
||||
status={statuses.get(item.id) || 'scheduled'}
|
||||
onAction={() => onSchedulePress(item)}
|
||||
onDelete={() => onScheduleDelete(item)}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { composeRenderProps } from 'react-aria-components';
|
||||
import type { GridListItemProps } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -11,8 +10,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';
|
||||
|
||||
@@ -22,11 +21,9 @@ 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: ScheduleStatus;
|
||||
status: ScheduleStatusType;
|
||||
} & WithRequired<GridListItemProps<ScheduleEntity>, 'value'>;
|
||||
|
||||
export function SchedulesListItem({
|
||||
@@ -53,19 +50,13 @@ export function SchedulesListItem({
|
||||
id={schedule.id}
|
||||
value={schedule}
|
||||
textValue={schedule.name || t('Unnamed schedule')}
|
||||
style={composeRenderProps(style, propStyle => ({
|
||||
minHeight: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
borderBottom: `1px solid ${theme.tableBorder}`,
|
||||
...propStyle,
|
||||
}))}
|
||||
style={{ ...styles.mobileListItem, padding: '8px 16px', ...style }}
|
||||
actions={
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onDelete}
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
minHeight: ROW_HEIGHT,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -320,7 +320,7 @@ type PayeeIconsProps = {
|
||||
|
||||
function PayeeIcons({ transaction, transferAccount }: PayeeIconsProps) {
|
||||
const { id, schedule: scheduleId } = transaction;
|
||||
const { isLoading: isSchedulesLoading, data: schedules = [] } =
|
||||
const { isLoading: isSchedulesLoading, schedules = [] } =
|
||||
useCachedSchedules();
|
||||
const isPreview = isPreviewId(id);
|
||||
const schedule = schedules.find(s => s.id === scheduleId);
|
||||
|
||||
@@ -337,8 +337,9 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
onLoaded,
|
||||
});
|
||||
|
||||
const { data: schedules = [] } = useSchedules({
|
||||
query: q('schedules').select('*'),
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
const { schedules } = useSchedules({
|
||||
query: schedulesQuery,
|
||||
});
|
||||
|
||||
const categories = useBudgetAutomationCategories();
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgArrowDown } from '@actual-app/components/icons/v1';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Information } from '@desktop-client/components/alerts';
|
||||
import {
|
||||
Modal,
|
||||
ModalButtons,
|
||||
ModalHeader,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
const mergePayeeStyle = {
|
||||
padding: 10,
|
||||
border: 'solid',
|
||||
borderWidth: 1,
|
||||
borderRadius: 6,
|
||||
borderColor: theme.tableBorder,
|
||||
backgroundColor: theme.tableBackground,
|
||||
};
|
||||
|
||||
const targetPayeeStyle = {
|
||||
...mergePayeeStyle,
|
||||
backgroundColor: theme.tableRowBackgroundHighlight,
|
||||
};
|
||||
|
||||
type ConfirmPayeesMergeModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'confirm-payees-merge' }
|
||||
>['options'];
|
||||
|
||||
export function ConfirmPayeesMergeModal({
|
||||
payeeIds,
|
||||
targetPayeeId,
|
||||
onConfirm,
|
||||
}: ConfirmPayeesMergeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: allPayees = [] } = usePayees();
|
||||
|
||||
const mergePayees = allPayees.filter(p => payeeIds.includes(p.id));
|
||||
|
||||
const targetPayee = allPayees.find(p => p.id === targetPayeeId);
|
||||
|
||||
if (!targetPayee || mergePayees.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal name="confirm-payees-merge">
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader title={t('Confirm Merge')} />
|
||||
|
||||
<View style={{ maxWidth: 500, marginTop: 20 }}>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: '100%', flexDirection: 'column', gap: 10 }}>
|
||||
{mergePayees.map(payee => (
|
||||
<View style={mergePayeeStyle} key={payee.id}>
|
||||
<Text>{payee.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
<SvgArrowDown width={20} height={20} />
|
||||
<View style={{ width: '100%' }}>
|
||||
<View style={targetPayeeStyle}>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
color: theme.tableRowBackgroundHighlightText,
|
||||
}}
|
||||
>
|
||||
{targetPayee.name}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Information style={{ marginTop: 20 }}>
|
||||
<Trans>
|
||||
Merging will delete the selected payee(s) and transfer any
|
||||
associated rules to the target payee.
|
||||
</Trans>
|
||||
</Information>
|
||||
|
||||
<ModalButtons style={{ marginTop: 20 }} focusButton>
|
||||
<Button style={{ marginRight: 10 }} onPress={() => state.close()}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ marginRight: 10 }}
|
||||
onPress={async () => {
|
||||
onConfirm?.();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<Trans>Merge</Trans>
|
||||
</Button>
|
||||
</ModalButtons>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -85,8 +85,24 @@ export function CreateAccountModal({
|
||||
|
||||
try {
|
||||
const results = await send('simplefin-accounts');
|
||||
if (results.error_code === 'INVALID_ACCESS_TOKEN') {
|
||||
setLoadingSimpleFinAccounts(false);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (results.error_code) {
|
||||
throw new Error(results.reason);
|
||||
throw new Error(
|
||||
results.reason || `SimpleFIN error: ${results.error_code}`,
|
||||
);
|
||||
}
|
||||
|
||||
const newAccounts = [];
|
||||
@@ -127,12 +143,12 @@ export function CreateAccountModal({
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'simplefin-init',
|
||||
options: {
|
||||
onSuccess: () => setIsSimpleFinSetupComplete(true),
|
||||
},
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
title: t('Error when trying to contact SimpleFIN'),
|
||||
message: (err as Error).message || t('An unknown error occurred.'),
|
||||
timeout: 10000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
@@ -21,7 +22,7 @@ import {
|
||||
ModalTitle,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useSchedule } from '@desktop-client/hooks/useSchedule';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
|
||||
type ScheduledTransactionMenuModalProps = Extract<
|
||||
@@ -43,12 +44,19 @@ export function ScheduledTransactionMenuModal({
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
};
|
||||
const scheduleId = transactionId?.split('/')?.[1];
|
||||
const { isPending: isSchedulesLoading, data: schedule } =
|
||||
useSchedule(scheduleId);
|
||||
if (isSchedulesLoading || !schedule) {
|
||||
const schedulesQuery = useMemo(
|
||||
() => q('schedules').filter({ id: scheduleId }).select('*'),
|
||||
[scheduleId],
|
||||
);
|
||||
const { isLoading: isSchedulesLoading, schedules } = useSchedules({
|
||||
query: schedulesQuery,
|
||||
});
|
||||
|
||||
if (isSchedulesLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const schedule = schedules?.[0];
|
||||
const { date: dateCond } = extractScheduleConds(schedule._conditions);
|
||||
|
||||
const canBeSkipped = scheduleIsRecurring(dateCond);
|
||||
@@ -59,7 +67,7 @@ export function ScheduledTransactionMenuModal({
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={<ModalTitle title={schedule.name || ''} shrinkOnOverflow />}
|
||||
title={<ModalTitle title={schedule?.name || ''} shrinkOnOverflow />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
@@ -73,7 +81,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
|
||||
|
||||
@@ -191,25 +191,9 @@ export const ManagePayees = ({
|
||||
|
||||
async function onMerge() {
|
||||
const ids = [...selected.items];
|
||||
if (ids.length < 2) return;
|
||||
await props.onMerge(ids);
|
||||
|
||||
const targetPayeeId = ids[0];
|
||||
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'confirm-payees-merge',
|
||||
options: {
|
||||
payeeIds: ids.filter(id => id !== targetPayeeId),
|
||||
targetPayeeId,
|
||||
onConfirm: async () => {
|
||||
await props.onMerge(ids);
|
||||
selected.dispatch({ type: 'select-none' });
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
selected.dispatch({ type: 'select-none' });
|
||||
}
|
||||
|
||||
const onChangeCategoryLearning = useCallback(() => {
|
||||
|
||||
@@ -697,7 +697,6 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
onMakeAsNonSplitTransactions={() => {}}
|
||||
showSelection={false}
|
||||
allowSplitTransaction={false}
|
||||
allowReorder={false}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
) : (
|
||||
|
||||
@@ -46,7 +46,6 @@ import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useRuleConditionFilters } from '@desktop-client/hooks/useRuleConditionFilters';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useUpdateDashboardWidgetMutation } from '@desktop-client/reports/mutations';
|
||||
@@ -74,9 +73,6 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const [budgetTypePref] = useSyncedPref('budgetType');
|
||||
const budgetType: 'envelope' | 'tracking' =
|
||||
budgetTypePref === 'tracking' ? 'tracking' : 'envelope';
|
||||
|
||||
const {
|
||||
conditions,
|
||||
@@ -149,9 +145,8 @@ function SpendingInternal({ widget }: SpendingInternalProps) {
|
||||
conditionsOp,
|
||||
compare,
|
||||
compareTo,
|
||||
budgetType,
|
||||
}),
|
||||
[conditions, conditionsOp, compare, compareTo, budgetType],
|
||||
[conditions, conditionsOp, compare, compareTo],
|
||||
);
|
||||
|
||||
const data = useReport('default', getGraphData);
|
||||
|
||||
@@ -21,7 +21,6 @@ import { createSpendingSpreadsheet } from '@desktop-client/components/reports/sp
|
||||
import { useDashboardWidgetCopyMenu } from '@desktop-client/components/reports/useDashboardWidgetCopyMenu';
|
||||
import { useReport } from '@desktop-client/components/reports/useReport';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
|
||||
type SpendingCardProps = {
|
||||
widgetId: string;
|
||||
@@ -42,9 +41,6 @@ export function SpendingCard({
|
||||
}: SpendingCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const [budgetTypePref] = useSyncedPref('budgetType');
|
||||
const budgetType: 'envelope' | 'tracking' =
|
||||
budgetTypePref === 'tracking' ? 'tracking' : 'envelope';
|
||||
|
||||
const [isCardHovered, setIsCardHovered] = useState(false);
|
||||
const [nameMenuOpen, setNameMenuOpen] = useState(false);
|
||||
@@ -64,9 +60,8 @@ export function SpendingCard({
|
||||
conditionsOp: meta?.conditionsOp,
|
||||
compare,
|
||||
compareTo,
|
||||
budgetType,
|
||||
});
|
||||
}, [meta?.conditions, meta?.conditionsOp, compare, compareTo, budgetType]);
|
||||
}, [meta?.conditions, meta?.conditionsOp, compare, compareTo]);
|
||||
|
||||
const data = useReport('default', getGraphData);
|
||||
const todayDay =
|
||||
|
||||
@@ -20,7 +20,6 @@ type createSpendingSpreadsheetProps = {
|
||||
conditionsOp?: string;
|
||||
compare?: string;
|
||||
compareTo?: string;
|
||||
budgetType?: 'envelope' | 'tracking';
|
||||
};
|
||||
|
||||
export function createSpendingSpreadsheet({
|
||||
@@ -28,7 +27,6 @@ export function createSpendingSpreadsheet({
|
||||
conditionsOp,
|
||||
compare,
|
||||
compareTo,
|
||||
budgetType = 'envelope',
|
||||
}: createSpendingSpreadsheetProps) {
|
||||
const startDate = monthUtils.subMonths(compare, 3) + '-01';
|
||||
const endDate = monthUtils.getMonthEnd(compare + '-01');
|
||||
@@ -115,11 +113,9 @@ export function createSpendingSpreadsheet({
|
||||
const combineDebts = [...debts, ...overlapDebts];
|
||||
|
||||
const budgetMonth = parseInt(compare.replace('-', ''));
|
||||
const budgetTable =
|
||||
budgetType === 'tracking' ? 'reflect_budgets' : 'zero_budgets';
|
||||
const [budgets] = await Promise.all([
|
||||
aqlQuery(
|
||||
q(budgetTable)
|
||||
q('zero_budgets')
|
||||
.filter({
|
||||
$and: [{ month: { $eq: budgetMonth } }],
|
||||
})
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
parse,
|
||||
unparse,
|
||||
} from 'loot-core/shared/rules';
|
||||
import type { ScheduleStatus } from 'loot-core/shared/schedules';
|
||||
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
|
||||
import type {
|
||||
NewRuleEntity,
|
||||
RuleActionEntity,
|
||||
@@ -58,8 +58,7 @@ 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 { useSchedule } from '@desktop-client/hooks/useSchedule';
|
||||
import { useScheduleStatus } from '@desktop-client/hooks/useScheduleStatus';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import {
|
||||
SelectedProvider,
|
||||
useSelected,
|
||||
@@ -366,22 +365,27 @@ function ScheduleDescription({ id }) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const format = useFormat();
|
||||
const { data: schedule, isLoading: isSchedulesLoading } = useSchedule(id);
|
||||
|
||||
const scheduleQuery = useMemo(
|
||||
() => q('schedules').filter({ id }).select('*'),
|
||||
[id],
|
||||
);
|
||||
const {
|
||||
data: { statusLookup = {} } = {},
|
||||
isLoading: isScheduleStatusLoading,
|
||||
} = useScheduleStatus({ schedules: [schedule] });
|
||||
schedules,
|
||||
statusLabels,
|
||||
isLoading: isSchedulesLoading,
|
||||
} = useSchedules({ query: scheduleQuery });
|
||||
|
||||
if (isSchedulesLoading || isScheduleStatusLoading) {
|
||||
if (isSchedulesLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!schedule) {
|
||||
const [schedule] = schedules;
|
||||
|
||||
if (schedule && schedules.length === 0) {
|
||||
return <View style={{ flex: 1 }}>{id}</View>;
|
||||
}
|
||||
|
||||
const status = statusLookup[schedule.id] as ScheduleStatus;
|
||||
const status = statusLabels.get(schedule.id) as ScheduleStatusType;
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
@@ -20,11 +20,10 @@ type ScheduleValueProps = {
|
||||
export function ScheduleValue({ value }: ScheduleValueProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: byId = {} } = usePayeesById();
|
||||
const { data: schedules = [], isLoading: isSchedulesLoading } = useSchedules({
|
||||
query: q('schedules').select('*'),
|
||||
});
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
const { schedules = [], isLoading } = useSchedules({ query: schedulesQuery });
|
||||
|
||||
if (isSchedulesLoading) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View aria-label={t('Loading...')} style={{ display: 'inline-flex' }}>
|
||||
<AnimatedLoading width={10} height={10} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useRef, useState } from 'react';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -10,7 +10,6 @@ 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';
|
||||
|
||||
@@ -21,7 +20,6 @@ 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';
|
||||
@@ -41,23 +39,24 @@ export function ScheduleLink({
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const [filter, setFilter] = useState(accountName || '');
|
||||
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
|
||||
query: q('schedules').filter({ completed: false }).select('*'),
|
||||
});
|
||||
|
||||
const schedulesQuery = useMemo(
|
||||
() => q('schedules').filter({ completed: false }).select('*'),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
isLoading: isScheduleStatusLoading,
|
||||
data: { statusLookup = {} },
|
||||
} = useScheduleStatus({ schedules });
|
||||
isLoading: isSchedulesLoading,
|
||||
schedules,
|
||||
statuses,
|
||||
} = useSchedules({ query: schedulesQuery });
|
||||
|
||||
const searchInput = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
async function onSelect(schedule: ScheduleEntity) {
|
||||
async function onSelect(scheduleId: string) {
|
||||
if (ids?.length > 0) {
|
||||
await send('transactions-batch-update', {
|
||||
updated: ids.map(id => ({ id, schedule: schedule.id })),
|
||||
updated: ids.map(id => ({ id, schedule: scheduleId })),
|
||||
});
|
||||
onScheduleLinked?.(schedule);
|
||||
onScheduleLinked?.(schedules.find(s => s.id === scheduleId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,16 +143,16 @@ export function ScheduleLink({
|
||||
}}
|
||||
>
|
||||
<SchedulesTable
|
||||
isLoading={isSchedulesLoading || isScheduleStatusLoading}
|
||||
isLoading={isSchedulesLoading}
|
||||
allowCompleted={false}
|
||||
filter={filter}
|
||||
minimal
|
||||
onSelect={schedule => {
|
||||
void onSelect(schedule);
|
||||
onSelect={id => {
|
||||
void onSelect(id);
|
||||
state.close();
|
||||
}}
|
||||
schedules={schedules}
|
||||
statusLookup={statusLookup}
|
||||
statuses={statuses}
|
||||
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 {
|
||||
ScheduleStatus,
|
||||
ScheduleStatusLookup,
|
||||
ScheduleStatuses,
|
||||
ScheduleStatusType,
|
||||
} from 'loot-core/shared/schedules';
|
||||
import type { ScheduleEntity } from 'loot-core/types/models';
|
||||
|
||||
@@ -39,14 +39,13 @@ 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[];
|
||||
statusLookup: ScheduleStatusLookup;
|
||||
statuses: ScheduleStatuses;
|
||||
filter: string;
|
||||
allowCompleted: boolean;
|
||||
onSelect: (schedule: ScheduleEntity) => void;
|
||||
onSelect: (id: ScheduleEntity['id']) => void;
|
||||
style: CSSProperties;
|
||||
tableStyle?: CSSProperties;
|
||||
} & (
|
||||
@@ -82,7 +81,7 @@ function OverflowMenu({
|
||||
onAction,
|
||||
}: {
|
||||
schedule: ScheduleEntity;
|
||||
status: ScheduleStatus;
|
||||
status: ScheduleStatusType;
|
||||
onAction: SchedulesTableProps['onAction'];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -204,13 +203,15 @@ function ScheduleRow({
|
||||
onAction,
|
||||
onSelect,
|
||||
minimal,
|
||||
statusLookup,
|
||||
statuses,
|
||||
dateFormat,
|
||||
}: {
|
||||
schedule: ScheduleEntity;
|
||||
statusLookup: ScheduleStatusLookup;
|
||||
dateFormat: string;
|
||||
} & Pick<SchedulesTableProps, 'onSelect' | 'onAction' | 'minimal'>) {
|
||||
} & Pick<
|
||||
SchedulesTableProps,
|
||||
'onSelect' | 'onAction' | 'minimal' | 'statuses'
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const rowRef = useRef(null);
|
||||
@@ -229,7 +230,7 @@ function ScheduleRow({
|
||||
ref={rowRef}
|
||||
height={ROW_HEIGHT}
|
||||
inset={15}
|
||||
onClick={() => onSelect(schedule)}
|
||||
onClick={() => onSelect(schedule.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: theme.tableBackground,
|
||||
@@ -250,7 +251,7 @@ function ScheduleRow({
|
||||
>
|
||||
<OverflowMenu
|
||||
schedule={schedule}
|
||||
status={statusLookup[schedule.id]}
|
||||
status={statuses.get(schedule.id)}
|
||||
onAction={(action, id) => {
|
||||
onAction(action, id);
|
||||
resetPosition();
|
||||
@@ -283,7 +284,7 @@ function ScheduleRow({
|
||||
: null}
|
||||
</Field>
|
||||
<Field width={120} name="status" style={{ alignItems: 'flex-start' }}>
|
||||
<StatusBadge status={statusLookup[schedule.id]} />
|
||||
<StatusBadge status={statuses.get(schedule.id)} />
|
||||
</Field>
|
||||
<ScheduleAmountCell amount={schedule._amount} op={schedule._amountOp} />
|
||||
{!minimal && (
|
||||
@@ -323,7 +324,7 @@ function ScheduleRow({
|
||||
export function SchedulesTable({
|
||||
isLoading,
|
||||
schedules,
|
||||
statusLookup,
|
||||
statuses,
|
||||
filter,
|
||||
minimal,
|
||||
allowCompleted,
|
||||
@@ -370,11 +371,11 @@ export function SchedulesTable({
|
||||
filterIncludes(payee && payee.name) ||
|
||||
filterIncludes(account && account.name) ||
|
||||
filterIncludes(amountStr) ||
|
||||
filterIncludes(statusLookup[schedule.id]) ||
|
||||
filterIncludes(statuses.get(schedule.id)) ||
|
||||
filterIncludes(dateStr)
|
||||
);
|
||||
});
|
||||
}, [payees, accounts, schedules, filter, statusLookup, format, dateFormat]);
|
||||
}, [payees, accounts, schedules, filter, statuses, format, dateFormat]);
|
||||
|
||||
const items: readonly SchedulesTableItem[] = useMemo(() => {
|
||||
const unCompletedSchedules = filteredSchedules.filter(s => !s.completed);
|
||||
@@ -422,7 +423,7 @@ export function SchedulesTable({
|
||||
return (
|
||||
<ScheduleRow
|
||||
schedule={item as ScheduleEntity}
|
||||
{...{ statusLookup, dateFormat, onSelect, onAction, minimal }}
|
||||
{...{ statuses, 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 { ScheduleStatus } from 'loot-core/shared/schedules';
|
||||
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
|
||||
import { titleFirst } from 'loot-core/shared/util';
|
||||
|
||||
// Consists of Schedule Statuses + Transaction statuses
|
||||
export type ScheduleTransactionStatus =
|
||||
| ScheduleStatus
|
||||
export type StatusTypes =
|
||||
| ScheduleStatusType
|
||||
| 'cleared'
|
||||
| 'pending'
|
||||
| 'reconciled';
|
||||
@@ -31,7 +31,7 @@ export const defaultStatusProps = {
|
||||
Icon: SvgCheckCircleHollow,
|
||||
};
|
||||
|
||||
export function getStatusProps(status?: ScheduleTransactionStatus | null) {
|
||||
export function getStatusProps(status: StatusTypes | null | undefined) {
|
||||
switch (status) {
|
||||
case 'missed':
|
||||
return {
|
||||
@@ -92,7 +92,7 @@ export function getStatusProps(status?: ScheduleTransactionStatus | null) {
|
||||
}
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: ScheduleTransactionStatus }) {
|
||||
export function StatusBadge({ status }: { status: ScheduleStatusType }) {
|
||||
const { color, backgroundColor, Icon } = getStatusProps(status);
|
||||
return (
|
||||
<View
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -15,7 +15,6 @@ 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';
|
||||
|
||||
@@ -26,11 +25,9 @@ export function Schedules() {
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const onEdit = useCallback(
|
||||
(schedule: ScheduleEntity) => {
|
||||
(id: ScheduleEntity['id']) => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: { name: 'schedule-edit', options: { id: schedule.id } },
|
||||
}),
|
||||
pushModal({ modal: { name: 'schedule-edit', options: { id } } }),
|
||||
);
|
||||
},
|
||||
[dispatch],
|
||||
@@ -81,14 +78,12 @@ export function Schedules() {
|
||||
[],
|
||||
);
|
||||
|
||||
const { isLoading: isSchedulesLoading, data: schedules = [] } = useSchedules({
|
||||
query: q('schedules').select('*'),
|
||||
});
|
||||
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
const {
|
||||
isLoading: isScheduleStatusLoading,
|
||||
data: { statusLookup = {} } = {},
|
||||
} = useScheduleStatus({ schedules });
|
||||
isLoading: isSchedulesLoading,
|
||||
schedules,
|
||||
statuses,
|
||||
} = useSchedules({ query: schedulesQuery });
|
||||
|
||||
return (
|
||||
<Page header={t('Schedules')}>
|
||||
@@ -115,10 +110,10 @@ export function Schedules() {
|
||||
</View>
|
||||
|
||||
<SchedulesTable
|
||||
isLoading={isSchedulesLoading || isScheduleStatusLoading}
|
||||
isLoading={isSchedulesLoading}
|
||||
schedules={schedules}
|
||||
filter={filter}
|
||||
statusLookup={statusLookup}
|
||||
statuses={statuses}
|
||||
allowCompleted
|
||||
onSelect={onEdit}
|
||||
onAction={onAction}
|
||||
|
||||
@@ -38,14 +38,12 @@ type ThemeInstallerProps = {
|
||||
onInstall: (theme: InstalledTheme) => void;
|
||||
onClose: () => void;
|
||||
installedTheme?: InstalledTheme | null;
|
||||
mode?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
export function ThemeInstaller({
|
||||
onInstall,
|
||||
onClose,
|
||||
installedTheme,
|
||||
mode,
|
||||
}: ThemeInstallerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedCatalogTheme, setSelectedCatalogTheme] =
|
||||
@@ -248,9 +246,9 @@ export function ThemeInstaller({
|
||||
return null;
|
||||
}
|
||||
|
||||
const catalogItems = [...(catalog ?? [])]
|
||||
.filter(catalogTheme => !mode || catalogTheme.mode === mode)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const catalogItems = [...(catalog ?? [])].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
const itemsPerRow = getItemsPerRow(width);
|
||||
const rows: CatalogTheme[][] = [];
|
||||
for (let i = 0; i < catalogItems.length; i += itemsPerRow) {
|
||||
|
||||
@@ -285,11 +285,6 @@ export function ThemeSettings() {
|
||||
? installedCustomDarkTheme
|
||||
: installedCustomLightTheme
|
||||
}
|
||||
mode={
|
||||
showInstaller === 'light' || showInstaller === 'dark'
|
||||
? showInstaller
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type { DropPosition as AriaDropPosition } from 'react-aria';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -151,9 +150,7 @@ type ItemPosition = 'first' | 'last' | null;
|
||||
export const DropHighlightPosContext = createContext<ItemPosition>(null);
|
||||
|
||||
type DropHighlightProps = {
|
||||
// Supports legacy ('top'/'bottom') and react-aria ('before'/'after'/'on') positions
|
||||
// 'on' is not used in our UI but is included for type compatibility
|
||||
pos: DropPosition | AriaDropPosition | null;
|
||||
pos: DropPosition;
|
||||
offset?: {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
@@ -162,17 +159,15 @@ type DropHighlightProps = {
|
||||
export function DropHighlight({ pos, offset }: DropHighlightProps) {
|
||||
const itemPos = useContext(DropHighlightPosContext);
|
||||
|
||||
// 'on' position is not supported for highlight (used for dropping onto items, not between)
|
||||
if (pos == null || pos === 'on') {
|
||||
if (pos == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top || 0);
|
||||
const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom || 0);
|
||||
|
||||
// Support both legacy ('top'/'bottom') and aria ('before'/'after') position names
|
||||
const isTop = pos === 'top' || pos === 'before';
|
||||
const posStyle = isTop ? { top: topOffset } : { bottom: bottomOffset };
|
||||
const posStyle =
|
||||
pos === 'top' ? { top: -2 + topOffset } : { bottom: -1 + bottomOffset };
|
||||
|
||||
return (
|
||||
<View
|
||||
|
||||