Compare commits
129 Commits
react-aria
...
v24.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44375e72ad | ||
|
|
6454c10e63 | ||
|
|
2a9546ced1 | ||
|
|
8926ff69b1 | ||
|
|
340169bfb6 | ||
|
|
3a905d3f9a | ||
|
|
7738ea0c00 | ||
|
|
8e077e0282 | ||
|
|
ae608f0cb8 | ||
|
|
f1c0d0b8a6 | ||
|
|
d9adb750d4 | ||
|
|
1750cd9081 | ||
|
|
7769d0303e | ||
|
|
9108b63355 | ||
|
|
1b70e59bde | ||
|
|
b48d256ec4 | ||
|
|
9c0e6a307b | ||
|
|
3e5ce72e27 | ||
|
|
b347f03fbb | ||
|
|
f3660c166f | ||
|
|
aaf96bbc2c | ||
|
|
6d84b0e371 | ||
|
|
db4b504e53 | ||
|
|
d6afc85a8c | ||
|
|
ee21155d1a | ||
|
|
65a7c58441 | ||
|
|
51ec600de2 | ||
|
|
af5fd5b3ef | ||
|
|
eccdc52342 | ||
|
|
4c192d7e1e | ||
|
|
f715ceafc9 | ||
|
|
af73dcd722 | ||
|
|
5e3485a8e2 | ||
|
|
1458dbc307 | ||
|
|
9ac77af077 | ||
|
|
3e07d18acd | ||
|
|
fa6cc26416 | ||
|
|
a1ca871b24 | ||
|
|
d9066a49c4 | ||
|
|
63ad6dadf2 | ||
|
|
89b096aa65 | ||
|
|
ee0156d35d | ||
|
|
9c17d55e0d | ||
|
|
411a6791b2 | ||
|
|
6f3af7b609 | ||
|
|
43ff1c033e | ||
|
|
09c44d351d | ||
|
|
a22160579d | ||
|
|
81df2ce7fd | ||
|
|
119d0b339d | ||
|
|
d1362c3d74 | ||
|
|
8142dd1ec9 | ||
|
|
2afd6967b4 | ||
|
|
fe922ec22e | ||
|
|
30a70f5627 | ||
|
|
65c5f2c559 | ||
|
|
1abca7619d | ||
|
|
6a85f84565 | ||
|
|
65329398fd | ||
|
|
a2e434a1fb | ||
|
|
d2bbe6a98e | ||
|
|
2c1967d788 | ||
|
|
798aee78c3 | ||
|
|
2807c98c2c | ||
|
|
5e9b976676 | ||
|
|
44ce976ffa | ||
|
|
5ba80fcbdc | ||
|
|
7b77f60458 | ||
|
|
81f59ff776 | ||
|
|
63d9547e7c | ||
|
|
d18fd36ae1 | ||
|
|
2b1ba88983 | ||
|
|
8be867f884 | ||
|
|
cafe480ba4 | ||
|
|
6472c70960 | ||
|
|
56c5a533e7 | ||
|
|
7e3ff1ad03 | ||
|
|
e0d7233b40 | ||
|
|
1b4c4319e1 | ||
|
|
14f29941b0 | ||
|
|
4389329bfa | ||
|
|
3a38c32b4c | ||
|
|
c3c6acd37c | ||
|
|
8de0f6a72a | ||
|
|
2799dbee3e | ||
|
|
58eeee825e | ||
|
|
6653dca776 | ||
|
|
77ba15f54c | ||
|
|
653a0ab104 | ||
|
|
2c26fa51a3 | ||
|
|
dff9911a15 | ||
|
|
3d5818f017 | ||
|
|
efd294dcef | ||
|
|
0eb62a09bc | ||
|
|
73d52fa0d0 | ||
|
|
5b0cc63f73 | ||
|
|
26a591f07f | ||
|
|
fe8851c797 | ||
|
|
511f677ae4 | ||
|
|
1cef0d11ee | ||
|
|
536cabb75b | ||
|
|
cceda03905 | ||
|
|
982f555a21 | ||
|
|
fe70ecb635 | ||
|
|
5c0bee6031 | ||
|
|
4439bb6abe | ||
|
|
b432204b4b | ||
|
|
9a85a72089 | ||
|
|
a970a78932 | ||
|
|
ed65805d53 | ||
|
|
88ae7e9375 | ||
|
|
0135a4d1b9 | ||
|
|
4af2c4f214 | ||
|
|
89a8f102dc | ||
|
|
d032fce7ea | ||
|
|
2fdc7fef32 | ||
|
|
1e41d695c5 | ||
|
|
12f91f7d86 | ||
|
|
f75d0f8099 | ||
|
|
07bbe00059 | ||
|
|
be0d363576 | ||
|
|
c2e648c9d5 | ||
|
|
33049a77e7 | ||
|
|
89241623f3 | ||
|
|
8434e8f5ce | ||
|
|
9b99debacc | ||
|
|
a23ec33591 | ||
|
|
aaea04fc00 | ||
|
|
b4f0087eef |
270
.eslintrc.js
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable rulesdir/typography */
|
||||
const path = require('path');
|
||||
|
||||
const rulesDirPlugin = require('eslint-plugin-rulesdir');
|
||||
@@ -34,9 +33,23 @@ const restrictedImportColors = [
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
jest: true,
|
||||
node: true,
|
||||
},
|
||||
plugins: [
|
||||
'prettier',
|
||||
'import',
|
||||
'rulesdir',
|
||||
'@typescript-eslint',
|
||||
'jsx-a11y',
|
||||
'react-hooks',
|
||||
],
|
||||
extends: [
|
||||
'react-app',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
'plugin:prettier/recommended',
|
||||
@@ -51,6 +64,184 @@ module.exports = {
|
||||
vi: true,
|
||||
},
|
||||
rules: {
|
||||
// http://eslint.org/docs/rules/
|
||||
'array-callback-return': 'warn',
|
||||
'default-case': ['warn', { commentPattern: '^no default$' }],
|
||||
'dot-location': ['warn', 'property'],
|
||||
eqeqeq: ['warn', 'smart'],
|
||||
'new-parens': 'warn',
|
||||
'no-array-constructor': 'warn',
|
||||
'no-caller': 'warn',
|
||||
'no-cond-assign': ['warn', 'except-parens'],
|
||||
'no-const-assign': 'warn',
|
||||
'no-control-regex': 'warn',
|
||||
'no-delete-var': 'warn',
|
||||
'no-dupe-args': 'warn',
|
||||
'no-dupe-class-members': 'warn',
|
||||
'no-dupe-keys': 'warn',
|
||||
'no-duplicate-case': 'warn',
|
||||
'no-empty-character-class': 'warn',
|
||||
'no-empty-pattern': 'warn',
|
||||
'no-eval': 'warn',
|
||||
'no-ex-assign': 'warn',
|
||||
'no-extend-native': 'warn',
|
||||
'no-extra-bind': 'warn',
|
||||
'no-extra-label': 'warn',
|
||||
'no-fallthrough': 'warn',
|
||||
'no-func-assign': 'warn',
|
||||
'no-implied-eval': 'warn',
|
||||
'no-invalid-regexp': 'warn',
|
||||
'no-iterator': 'warn',
|
||||
'no-label-var': 'warn',
|
||||
'no-labels': ['warn', { allowLoop: true, allowSwitch: false }],
|
||||
'no-lone-blocks': 'warn',
|
||||
'no-mixed-operators': [
|
||||
'warn',
|
||||
{
|
||||
groups: [
|
||||
['&', '|', '^', '~', '<<', '>>', '>>>'],
|
||||
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
|
||||
['&&', '||'],
|
||||
['in', 'instanceof'],
|
||||
],
|
||||
allowSamePrecedence: false,
|
||||
},
|
||||
],
|
||||
'no-multi-str': 'warn',
|
||||
'no-global-assign': 'warn',
|
||||
'no-unsafe-negation': 'warn',
|
||||
'no-new-func': 'warn',
|
||||
'no-new-object': 'warn',
|
||||
'no-new-symbol': 'warn',
|
||||
'no-new-wrappers': 'warn',
|
||||
'no-obj-calls': 'warn',
|
||||
'no-octal': 'warn',
|
||||
'no-octal-escape': 'warn',
|
||||
'no-redeclare': 'warn',
|
||||
'no-regex-spaces': 'warn',
|
||||
'no-script-url': 'warn',
|
||||
'no-self-assign': 'warn',
|
||||
'no-self-compare': 'warn',
|
||||
'no-sequences': 'warn',
|
||||
'no-shadow-restricted-names': 'warn',
|
||||
'no-sparse-arrays': 'warn',
|
||||
'no-template-curly-in-string': 'warn',
|
||||
'no-this-before-super': 'warn',
|
||||
'no-throw-literal': 'warn',
|
||||
'no-undef': 'error',
|
||||
'no-unreachable': 'warn',
|
||||
'no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowShortCircuit: true,
|
||||
allowTernary: true,
|
||||
allowTaggedTemplates: true,
|
||||
},
|
||||
],
|
||||
'no-unused-labels': 'warn',
|
||||
'no-use-before-define': [
|
||||
'warn',
|
||||
{
|
||||
functions: false,
|
||||
classes: false,
|
||||
variables: false,
|
||||
},
|
||||
],
|
||||
'no-useless-computed-key': 'warn',
|
||||
'no-useless-concat': 'warn',
|
||||
'no-useless-constructor': 'warn',
|
||||
'no-useless-escape': 'warn',
|
||||
'no-useless-rename': [
|
||||
'warn',
|
||||
{
|
||||
ignoreDestructuring: false,
|
||||
ignoreImport: false,
|
||||
ignoreExport: false,
|
||||
},
|
||||
],
|
||||
'no-with': 'warn',
|
||||
'no-whitespace-before-property': 'warn',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'require-yield': 'warn',
|
||||
'rest-spread-spacing': ['warn', 'never'],
|
||||
strict: ['warn', 'never'],
|
||||
'unicode-bom': ['warn', 'never'],
|
||||
'use-isnan': 'warn',
|
||||
'valid-typeof': 'warn',
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'require',
|
||||
property: 'ensure',
|
||||
message:
|
||||
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
|
||||
},
|
||||
{
|
||||
object: 'System',
|
||||
property: 'import',
|
||||
message:
|
||||
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
|
||||
},
|
||||
],
|
||||
'getter-return': 'warn',
|
||||
|
||||
// https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules
|
||||
'import/first': 'error',
|
||||
'import/no-amd': 'error',
|
||||
'import/no-anonymous-default-export': 'warn',
|
||||
'import/no-webpack-loader-syntax': 'error',
|
||||
|
||||
// https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
|
||||
'react/forbid-foreign-prop-types': ['warn', { allowInPropTypes: true }],
|
||||
'react/jsx-no-comment-textnodes': 'warn',
|
||||
'react/jsx-no-duplicate-props': 'warn',
|
||||
'react/jsx-no-target-blank': 'warn',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': [
|
||||
'warn',
|
||||
{
|
||||
allowAllCaps: true,
|
||||
ignore: [],
|
||||
},
|
||||
],
|
||||
'react/no-danger-with-children': 'warn',
|
||||
// Disabled because of undesirable warnings
|
||||
// See https://github.com/facebook/create-react-app/issues/5204 for
|
||||
// blockers until its re-enabled
|
||||
// 'react/no-deprecated': 'warn',
|
||||
'react/no-direct-mutation-state': 'warn',
|
||||
'react/no-is-mounted': 'warn',
|
||||
'react/no-typos': 'error',
|
||||
'react/require-render-return': 'error',
|
||||
'react/style-prop-object': 'warn',
|
||||
|
||||
// https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
|
||||
'jsx-a11y/alt-text': 'warn',
|
||||
'jsx-a11y/anchor-has-content': 'warn',
|
||||
'jsx-a11y/anchor-is-valid': [
|
||||
'warn',
|
||||
{
|
||||
aspects: ['noHref', 'invalidHref'],
|
||||
},
|
||||
],
|
||||
'jsx-a11y/aria-activedescendant-has-tabindex': 'warn',
|
||||
'jsx-a11y/aria-props': 'warn',
|
||||
'jsx-a11y/aria-proptypes': 'warn',
|
||||
'jsx-a11y/aria-role': ['warn', { ignoreNonDOM: true }],
|
||||
'jsx-a11y/aria-unsupported-elements': 'warn',
|
||||
'jsx-a11y/heading-has-content': 'warn',
|
||||
'jsx-a11y/iframe-has-title': 'warn',
|
||||
'jsx-a11y/img-redundant-alt': 'warn',
|
||||
'jsx-a11y/no-access-key': 'warn',
|
||||
'jsx-a11y/no-distracting-elements': 'warn',
|
||||
'jsx-a11y/no-redundant-roles': 'warn',
|
||||
'jsx-a11y/role-has-required-aria-props': 'warn',
|
||||
'jsx-a11y/role-supports-aria-props': 'warn',
|
||||
'jsx-a11y/scope': 'warn',
|
||||
|
||||
// https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
|
||||
'prettier/prettier': 'warn',
|
||||
|
||||
// Note: base rule explicitly disabled in favor of the TS one
|
||||
@@ -60,6 +251,7 @@ module.exports = {
|
||||
{
|
||||
varsIgnorePattern: '^(_|React)',
|
||||
ignoreRestSiblings: true,
|
||||
caughtErrors: 'none',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -166,9 +358,63 @@ module.exports = {
|
||||
'prefer-const': 'warn',
|
||||
'prefer-spread': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts?(x)'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
|
||||
// typescript-eslint specific options
|
||||
warnOnUnsupportedTypeScriptVersion: true,
|
||||
},
|
||||
plugins: ['@typescript-eslint'],
|
||||
// If adding a typescript-eslint version of an existing ESLint rule,
|
||||
// make sure to disable the ESLint rule here.
|
||||
rules: {
|
||||
// TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906)
|
||||
'default-case': 'off',
|
||||
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/291)
|
||||
'no-dupe-class-members': 'off',
|
||||
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/477)
|
||||
'no-undef': 'off',
|
||||
|
||||
// Add TypeScript specific rules (and turn off ESLint equivalents)
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
'no-array-constructor': 'off',
|
||||
'@typescript-eslint/no-array-constructor': 'warn',
|
||||
'no-redeclare': 'off',
|
||||
'@typescript-eslint/no-redeclare': 'warn',
|
||||
'no-use-before-define': 'off',
|
||||
'@typescript-eslint/no-use-before-define': [
|
||||
'warn',
|
||||
{
|
||||
functions: false,
|
||||
classes: false,
|
||||
variables: false,
|
||||
typedefs: false,
|
||||
},
|
||||
],
|
||||
'no-unused-expressions': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': [
|
||||
'error',
|
||||
{
|
||||
allowShortCircuit: true,
|
||||
allowTernary: true,
|
||||
allowTaggedTemplates: true,
|
||||
},
|
||||
],
|
||||
'no-useless-constructor': 'off',
|
||||
'@typescript-eslint/no-useless-constructor': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['.eslintrc.js', './**/.eslintrc.js'],
|
||||
parserOptions: { project: null },
|
||||
@@ -189,7 +435,7 @@ module.exports = {
|
||||
'warn',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
],
|
||||
'@typescript-eslint/ban-types': [
|
||||
'@typescript-eslint/no-restricted-types': [
|
||||
'warn',
|
||||
{
|
||||
types: {
|
||||
@@ -197,7 +443,6 @@ module.exports = {
|
||||
FunctionComponent: { message: ruleFCMsg },
|
||||
FC: { message: ruleFCMsg },
|
||||
},
|
||||
extendDefaults: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -332,8 +577,23 @@ module.exports = {
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'.eslintrc.js',
|
||||
'*.test.js',
|
||||
'*.test.ts',
|
||||
'*.test.jsx',
|
||||
'*.test.tsx',
|
||||
],
|
||||
rules: {
|
||||
'rulesdir/typography': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
|
||||
19
.github/workflows/electron-master.yml
vendored
@@ -48,13 +48,17 @@ jobs:
|
||||
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
- name: Build Electron for Mac
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
env:
|
||||
# CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
# CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
- name: Build Electron
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -62,13 +66,22 @@ jobs:
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Add to Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
|
||||
8
.github/workflows/electron-pr.yml
vendored
@@ -52,5 +52,13 @@ jobs:
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.dmg
|
||||
packages/desktop-electron/dist/*.exe
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
|
||||
12
.github/workflows/stale.yml
vendored
@@ -7,10 +7,20 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v8
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
|
||||
days-before-stale: 30
|
||||
days-before-close: 5
|
||||
days-before-issue-stale: -1
|
||||
stale-wip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
|
||||
days-before-stale: 7
|
||||
any-of-labels: ':construction: WIP'
|
||||
days-before-close: -1
|
||||
days-before-issue-stale: -1
|
||||
|
||||
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
yarn lint-staged
|
||||
893
.yarn/releases/yarn-4.0.2.cjs
vendored
894
.yarn/releases/yarn-4.3.1.cjs
vendored
Executable file
@@ -4,4 +4,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.0.2.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.3.1.cjs
|
||||
|
||||
38
README.md
@@ -14,22 +14,40 @@ Want to say thanks? Click the ⭐ at the top of the page.
|
||||
|
||||
- Actual [discord](https://discord.gg/pRYNYr4W5A) community.
|
||||
- Actual [Community Documentation](https://actualbudget.org/docs)
|
||||
- [Frequently asked questions](https://actualbudget.org/docs/faq)
|
||||
|
||||
## Installation
|
||||
|
||||
If you are only interested in running the latest version and not contributing to the source code, you don't need to clone this repo. You can get the latest version through npm.
|
||||
There are four ways to deploy Actual:
|
||||
|
||||
### The easy way: using a server (recommended)
|
||||
1. One-click deployment [via PikaPods](https://www.pikapods.com/pods?run=actual) (~1.40 $/month) - recommended for non-technical users
|
||||
1. Managed hosting [via Fly.io](https://actualbudget.org/docs/install/fly) (~1.50 $/month)
|
||||
1. Self-hosted by using [a Docker image](https://actualbudget.org/docs/install/docker)
|
||||
1. Local-only apps - [downloadable Windows, Mac and Linux apps](https://actualbudget.org/download/) you can run on your device
|
||||
|
||||
The easiest way to get Actual running is to use the [actual-server](https://github.com/actualbudget/actual-server) project. That is the server for syncing changes across devices, and it comes with the latest version of Actual. The server will provide both the web project and a server for syncing.
|
||||
Learn more in the [installation instructions docs](https://actualbudget.org/docs/install/).
|
||||
|
||||
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.org/docs/install/local)
|
||||
## Ready to Start Budgeting?
|
||||
|
||||
Read about [Envelope budgeting](https://actualbudget.org/docs/getting-started/envelope-budgeting) to know more about the idea behind Actual Budget.
|
||||
|
||||
### Are you new to budgeting or want to start fresh?
|
||||
|
||||
Check out the community's [Starting Fresh](https://actualbudget.org/docs/getting-started/starting-fresh) guide so you can quickly get up and running!
|
||||
|
||||
### Are you migrating from other budgeting apps?
|
||||
|
||||
Check out the community's [Migration](https://actualbudget.org/docs/migration/) guide to start jumping on the Actual Budget train!
|
||||
|
||||
## Documentation
|
||||
|
||||
We have a wide range of documentation on how to use Actual, this is all available in our [Community Documentation](https://actualbudget.org/docs), this includes topics on Budgeting, Account Management, Tips & Tricks and some documentation for developers.
|
||||
|
||||
## Code structure
|
||||
## Contributing
|
||||
|
||||
Actual is a community driven product. Learn more about [contributing to Actual](https://actualbudget.org/docs/contributing/).
|
||||
|
||||
### Code structure
|
||||
|
||||
The Actual app is split up into a few packages:
|
||||
|
||||
@@ -39,13 +57,21 @@ The Actual app is split up into a few packages:
|
||||
|
||||
More information on the project structure is available in our [community documentation](https://actualbudget.org/docs/contributing/project-details).
|
||||
|
||||
## Feature Requests
|
||||
### Feature Requests
|
||||
|
||||
Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc).
|
||||
Vote for your favorite requests by reacting :+1: to the top comment of the request.
|
||||
|
||||
To add new feature requests, open a new Issue of the "Feature Request" type.
|
||||
|
||||
### Translation
|
||||
|
||||
Make Actual Budget accessible to more people by helping with the [Internationalization](https://actualbudget.org/docs/contributing/i18n/) of Actual. We are using a crowd sourcing tool to manage the translations, see our [Weblate Project](https://hosted.weblate.org/projects/actualbudget/). Weblate proudly supports open-source software projects through their [Libre plan](https://weblate.org/en/hosting/#libre).
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
## Sponsors
|
||||
|
||||
Thanks to our wonderful sponsors who make Actual budget possible!
|
||||
|
||||
@@ -34,8 +34,6 @@ if [ "$OSTYPE" == "msys" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
yarn rebuild-electron
|
||||
|
||||
yarn workspace loot-core build:node
|
||||
|
||||
yarn workspace @actual-app/web build --mode=desktop
|
||||
@@ -50,10 +48,10 @@ yarn workspace desktop-electron update-client
|
||||
if [ -f ../../.secret-tokens ]; then
|
||||
source ../../.secret-tokens
|
||||
fi
|
||||
yarn build --publish never --arm64 --x64
|
||||
yarn build
|
||||
|
||||
echo "\nCreated release"
|
||||
else
|
||||
SKIP_NOTARIZATION=true yarn build --publish never --x64
|
||||
SKIP_NOTARIZATION=true yarn build
|
||||
fi
|
||||
)
|
||||
|
||||
33
package.json
@@ -30,6 +30,7 @@
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"test": "yarn workspaces foreach --all --parallel --verbose run test",
|
||||
"test:debug": "yarn workspaces foreach --all --verbose run test",
|
||||
"e2e": "yarn workspaces foreach --all --parallel --verbose run e2e",
|
||||
@@ -40,24 +41,31 @@
|
||||
"lint": "eslint . --max-warnings 0 --ext .js,.jsx,.ts,.tsx",
|
||||
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
|
||||
"typecheck": "yarn tsc && tsc-strict",
|
||||
"jq": "./node_modules/node-jq/bin/jq"
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"confusing-browser-globals": "^1.0.11",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-react-app": "7.0.1",
|
||||
"eslint-import-resolver-typescript": "3.5.5",
|
||||
"eslint-plugin-import": "2.27.5",
|
||||
"eslint-plugin-prettier": "5.1.3",
|
||||
"eslint-plugin-react": "7.32.2",
|
||||
"eslint-import-resolver-typescript": "3.6.1",
|
||||
"eslint-plugin-import": "2.29.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.9.0",
|
||||
"eslint-plugin-prettier": "5.2.1",
|
||||
"eslint-plugin-react": "7.35.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-rulesdir": "^0.2.2",
|
||||
"husky": "^9.0.11",
|
||||
"lint-staged": "^15.2.9",
|
||||
"node-jq": "^4.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "3.2.4",
|
||||
"prettier": "3.3.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typescript": "^5.0.2",
|
||||
"typescript-strict-plugin": "^2.2.2-beta.2"
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"rollup": "4.9.4"
|
||||
@@ -65,7 +73,10 @@
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"packageManager": "yarn@4.0.2",
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,md,json}": "prettier --write"
|
||||
},
|
||||
"packageManager": "yarn@4.3.1",
|
||||
"browserslist": [
|
||||
"electron 24.0",
|
||||
"defaults"
|
||||
|
||||
@@ -81,28 +81,22 @@ describe('API CRUD operations', () => {
|
||||
expect(groups).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
hidden: false,
|
||||
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
|
||||
is_income: 0,
|
||||
is_income: false,
|
||||
name: 'Usual Expenses',
|
||||
sort_order: 16384,
|
||||
tombstone: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
hidden: false,
|
||||
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
|
||||
is_income: 0,
|
||||
is_income: false,
|
||||
name: 'Investments and Savings',
|
||||
sort_order: 32768,
|
||||
tombstone: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
hidden: 0,
|
||||
hidden: false,
|
||||
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
|
||||
is_income: 1,
|
||||
is_income: true,
|
||||
name: 'Income',
|
||||
sort_order: 32768,
|
||||
tombstone: 0,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
@@ -563,10 +557,10 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
|
||||
// delete rules
|
||||
await api.deleteRule(rules[1]);
|
||||
await api.deleteRule(rules[1].id);
|
||||
expect(await api.getRules()).toHaveLength(1);
|
||||
|
||||
await api.deleteRule(rules[0]);
|
||||
await api.deleteRule(rules[0].id);
|
||||
expect(await api.getRules()).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -165,6 +165,10 @@ export function deleteCategory(id, transferCategoryId?) {
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCommonPayees() {
|
||||
return send('api/common-payees-get');
|
||||
}
|
||||
|
||||
export function getPayees() {
|
||||
return send('api/payees-get');
|
||||
}
|
||||
@@ -201,6 +205,14 @@ export function updateRule(rule) {
|
||||
return send('api/rule-update', { rule });
|
||||
}
|
||||
|
||||
export function deleteRule(id) {
|
||||
return send('api/rule-delete', { id });
|
||||
export function deleteRule(id: string) {
|
||||
return send('api/rule-delete', id);
|
||||
}
|
||||
|
||||
export function holdBudgetForNextMonth(month, amount) {
|
||||
return send('api/budget-hold-for-next-month', { month, amount });
|
||||
}
|
||||
|
||||
export function resetBudgetHold(month) {
|
||||
return send('api/budget-reset-hold', { month });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "6.8.2",
|
||||
"version": "6.10.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
@@ -35,6 +35,6 @@
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.5.1",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"typescript": "^5.0.2"
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,6 @@
|
||||
"@types/uuid": "^9.0.2",
|
||||
"jest": "^27.5.1",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.0.2"
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import * as merkle from './merkle';
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
|
||||
node2 = node2[diffkey] || emptyTrie();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unreachable
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
describe('Timestamp', function () {
|
||||
|
||||
@@ -154,7 +154,7 @@ export class Timestamp {
|
||||
/**
|
||||
* maximum timestamp
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
static max = Timestamp.parse(
|
||||
'9999-12-31T23:59:59.999Z-FFFF-FFFFFFFFFFFFFFFF',
|
||||
)!;
|
||||
@@ -294,7 +294,7 @@ export class Timestamp {
|
||||
/**
|
||||
* zero/minimum timestamp
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
static zero = Timestamp.parse(
|
||||
'1970-01-01T00:00:00.000Z-0000-0000000000000000',
|
||||
)!;
|
||||
|
||||
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
@@ -16,8 +16,8 @@ export class AccountPage {
|
||||
this.cancelTransactionButton = this.page.getByRole('button', {
|
||||
name: 'Cancel',
|
||||
});
|
||||
this.menuButton = this.page.getByRole('button', {
|
||||
name: 'Menu',
|
||||
this.accountMenuButton = this.page.getByRole('button', {
|
||||
name: 'Account menu',
|
||||
});
|
||||
|
||||
this.transactionTable = this.page.getByTestId('transaction-table');
|
||||
@@ -30,16 +30,28 @@ export class AccountPage {
|
||||
this.selectTooltip = this.page.getByTestId('transactions-select-tooltip');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter details of a transaction
|
||||
*/
|
||||
async enterSingleTransaction(transaction) {
|
||||
await this.addNewTransactionButton.click();
|
||||
await this._fillTransactionFields(this.newTransactionRow, transaction);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish adding a transaction
|
||||
*/
|
||||
async addEnteredTransaction() {
|
||||
await this.addTransactionButton.click();
|
||||
await this.cancelTransactionButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single transaction
|
||||
*/
|
||||
async createSingleTransaction(transaction) {
|
||||
await this.addNewTransactionButton.click();
|
||||
|
||||
await this._fillTransactionFields(this.newTransactionRow, transaction);
|
||||
|
||||
await this.addTransactionButton.click();
|
||||
await this.cancelTransactionButton.click();
|
||||
await this.enterSingleTransaction(transaction);
|
||||
await this.addEnteredTransaction();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +94,15 @@ export class AccountPage {
|
||||
*/
|
||||
getNthTransaction(index) {
|
||||
const row = this.transactionTableRow.nth(index);
|
||||
|
||||
return this._getTransactionDetails(row);
|
||||
}
|
||||
|
||||
getEnteredTransaction() {
|
||||
return this._getTransactionDetails(this.newTransactionRow);
|
||||
}
|
||||
|
||||
_getTransactionDetails(row) {
|
||||
const account = row.getByTestId('account');
|
||||
|
||||
return {
|
||||
@@ -103,10 +124,10 @@ export class AccountPage {
|
||||
* Open the modal for closing the account.
|
||||
*/
|
||||
async clickCloseAccount() {
|
||||
await this.menuButton.click();
|
||||
await this.accountMenuButton.click();
|
||||
await this.page.getByRole('button', { name: 'Close Account' }).click();
|
||||
return new CloseAccountModal(
|
||||
this.page.locator('css=[aria-modal]'),
|
||||
this.page.getByTestId('close-account-modal'),
|
||||
this.page,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,22 +5,22 @@ export class ReportsPage {
|
||||
}
|
||||
|
||||
async waitToLoad() {
|
||||
return this.pageContent.getByRole('link', { name: /^Net/ }).waitFor();
|
||||
return this.pageContent.getByRole('button', { name: /^Net/ }).waitFor();
|
||||
}
|
||||
|
||||
async goToNetWorthPage() {
|
||||
await this.pageContent.getByRole('link', { name: /^Net/ }).click();
|
||||
await this.pageContent.getByRole('button', { name: /^Net/ }).click();
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async goToCashFlowPage() {
|
||||
await this.pageContent.getByRole('link', { name: /^Cash/ }).click();
|
||||
await this.pageContent.getByRole('button', { name: /^Cash/ }).click();
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async getAvailableReportList() {
|
||||
return this.pageContent
|
||||
.getByRole('link')
|
||||
.getByRole('button')
|
||||
.getByRole('heading')
|
||||
.allTextContents();
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export class RulesPage {
|
||||
await this._fillEditorFields(
|
||||
data.conditions,
|
||||
this.page.getByTestId('condition-list'),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,28 +64,19 @@ export class RulesPage {
|
||||
}
|
||||
|
||||
if (data.splits) {
|
||||
if (data.splits.beforeSplitActions) {
|
||||
let idx = data.actions?.length ?? 0;
|
||||
for (const splitActions of data.splits) {
|
||||
await this.page.getByTestId('add-split-transactions').click();
|
||||
await this._fillEditorFields(
|
||||
data.splits.beforeSplitActions,
|
||||
this.page.getByTestId('action-list'),
|
||||
splitActions,
|
||||
this.page.getByTestId('action-list').nth(idx),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.splits.splitActions) {
|
||||
let idx = data.splits?.beforeSplitActions.length ?? 0;
|
||||
for (const splitActions of data.splits.splitActions) {
|
||||
await this.page.getByTestId('add-split-transactions').click();
|
||||
await this._fillEditorFields(
|
||||
splitActions,
|
||||
this.page.getByTestId('action-list').nth(idx),
|
||||
);
|
||||
idx++;
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _fillEditorFields(data, rootElement) {
|
||||
async _fillEditorFields(data, rootElement, fieldFirst = false) {
|
||||
for (const idx in data) {
|
||||
const { field, op, value } = data[idx];
|
||||
|
||||
@@ -94,15 +86,24 @@ export class RulesPage {
|
||||
await rootElement.getByRole('button', { name: 'Add entry' }).click();
|
||||
}
|
||||
|
||||
if (op && !fieldFirst) {
|
||||
await row.getByTestId('op-select').getByRole('button').first().click();
|
||||
await this.page.getByRole('button', { name: op, exact: true }).click();
|
||||
}
|
||||
|
||||
if (field) {
|
||||
await row.getByRole('button').first().click();
|
||||
await row
|
||||
.getByTestId('field-select')
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click();
|
||||
await this.page
|
||||
.getByRole('button', { exact: true, name: field })
|
||||
.getByRole('button', { name: field, exact: true })
|
||||
.click();
|
||||
}
|
||||
|
||||
if (op) {
|
||||
await row.getByRole('button', { name: 'is' }).click();
|
||||
if (op && fieldFirst) {
|
||||
await row.getByTestId('op-select').getByRole('button').first().click();
|
||||
await this.page.getByRole('button', { name: op, exact: true }).click();
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
@@ -32,6 +32,7 @@ test.describe('Rules', () => {
|
||||
});
|
||||
|
||||
test('creates a rule and makes sure it is applied when creating a transaction', async () => {
|
||||
await rulesPage.searchFor('Fast Internet');
|
||||
await rulesPage.createRule({
|
||||
conditions: [
|
||||
{
|
||||
@@ -48,7 +49,6 @@ test.describe('Rules', () => {
|
||||
],
|
||||
});
|
||||
|
||||
await rulesPage.searchFor('Fast Internet');
|
||||
const rule = rulesPage.getNthRule(0);
|
||||
await expect(rule.conditions).toHaveText(['payee is Fast Internet']);
|
||||
await expect(rule.actions).toHaveText(['set category to General']);
|
||||
@@ -79,35 +79,34 @@ test.describe('Rules', () => {
|
||||
value: 'Ikea',
|
||||
},
|
||||
],
|
||||
splits: {
|
||||
beforeSplitActions: [
|
||||
actions: [
|
||||
{
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: 'food / entertainment',
|
||||
},
|
||||
],
|
||||
splits: [
|
||||
[
|
||||
{
|
||||
field: 'notes',
|
||||
value: 'food / entertainment',
|
||||
field: 'a fixed percent of the remainder',
|
||||
value: '90',
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
value: 'Entertainment',
|
||||
},
|
||||
],
|
||||
splitActions: [
|
||||
[
|
||||
{
|
||||
field: 'a fixed percent of the remainder',
|
||||
value: '90',
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
value: 'Entertainment',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'an equal portion of the remainder',
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
value: 'Food',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
field: 'an equal portion of the remainder',
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
value: 'Food',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const accountPage = await navigation.goToAccountPage(
|
||||
@@ -120,7 +119,7 @@ test.describe('Rules', () => {
|
||||
});
|
||||
|
||||
const transaction = accountPage.getNthTransaction(0);
|
||||
await expect(transaction.payee).toHaveText('Split');
|
||||
await expect(transaction.payee).toHaveText('Ikea');
|
||||
await expect(transaction.notes).toHaveText('food / entertainment');
|
||||
await expect(transaction.category).toHaveText('Split');
|
||||
await expect(transaction.debit).toHaveText('100.00');
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 106 KiB |
@@ -120,7 +120,7 @@ test.describe('Transactions', () => {
|
||||
]);
|
||||
|
||||
const firstTransaction = accountPage.getNthTransaction(0);
|
||||
await expect(firstTransaction.payee).toHaveText('Split');
|
||||
await expect(firstTransaction.payee).toHaveText('Krogger');
|
||||
await expect(firstTransaction.notes).toHaveText('Notes');
|
||||
await expect(firstTransaction.category).toHaveText('Split');
|
||||
await expect(firstTransaction.debit).toHaveText('333.33');
|
||||
@@ -141,4 +141,26 @@ test.describe('Transactions', () => {
|
||||
await expect(thirdTransaction.credit).toHaveText('');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('creates a transfer test transaction', async () => {
|
||||
await accountPage.enterSingleTransaction({
|
||||
payee: 'Bank of America',
|
||||
notes: 'Notes field',
|
||||
debit: '12.34',
|
||||
});
|
||||
|
||||
let transaction = accountPage.getEnteredTransaction();
|
||||
await expect(transaction.category.locator('input')).toHaveValue('Transfer');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
|
||||
await accountPage.addEnteredTransaction();
|
||||
|
||||
transaction = accountPage.getNthTransaction(0);
|
||||
await expect(transaction.payee).toHaveText('Bank of America');
|
||||
await expect(transaction.notes).toHaveText('Notes field');
|
||||
await expect(transaction.category).toHaveText('Transfer');
|
||||
await expect(transaction.debit).toHaveText('12.34');
|
||||
await expect(transaction.credit).toHaveText('');
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.3 KiB |
14
packages/desktop-client/i18next-parser.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
module.exports = {
|
||||
input: ['src/**/*.{js,jsx,ts,tsx}', '../loot-core/src/**/*.{js,jsx,ts,tsx}'],
|
||||
output: 'src/locale/$LOCALE.json',
|
||||
locales: ['en'],
|
||||
sort: true,
|
||||
keySeparator: false,
|
||||
namespaceSeparator: false,
|
||||
defaultValue: (locale, ns, key, value) => {
|
||||
if (locale === 'en') {
|
||||
return value || key;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "24.7.0",
|
||||
"version": "24.9.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
@@ -24,6 +24,7 @@
|
||||
"@types/promise-retry": "^1.1.6",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.1",
|
||||
"@types/react-grid-layout": "^1",
|
||||
"@types/react-modal": "^3.16.0",
|
||||
"@types/react-redux": "^7.1.25",
|
||||
"@types/uuid": "^9.0.2",
|
||||
@@ -39,6 +40,9 @@
|
||||
"downshift": "7.6.2",
|
||||
"focus-visible": "^4.1.5",
|
||||
"glamor": "^2.20.40",
|
||||
"i18next": "^23.11.5",
|
||||
"i18next-parser": "^9.0.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"inter-ui": "^3.19.3",
|
||||
"jest": "^27.5.1",
|
||||
"jest-watch-typeahead": "^2.2.2",
|
||||
@@ -49,12 +53,15 @@
|
||||
"promise-retry": "^2.0.1",
|
||||
"re-resizable": "^6.9.17",
|
||||
"react": "18.2.0",
|
||||
"react-aria": "^3.33.1",
|
||||
"react-aria-components": "^1.2.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.12",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-hotkeys-hook": "^4.5.0",
|
||||
"react-i18next": "^14.1.2",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-modal": "3.16.1",
|
||||
"react-redux": "7.2.9",
|
||||
@@ -86,6 +93,7 @@
|
||||
"build": "vite build",
|
||||
"build:browser": "cross-env ./bin/build-browser",
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
"generate:i18n": "i18next",
|
||||
"test": "vitest",
|
||||
"e2e": "npx playwright test --browser=chromium",
|
||||
"vrt": "cross-env VRT=true npx playwright test --browser=chromium"
|
||||
|
||||
@@ -57,6 +57,9 @@ export default defineConfig({
|
||||
timeout: 20000, // 20 seconds
|
||||
retries: 1,
|
||||
testDir: 'e2e/',
|
||||
reporter: !process.env.CI
|
||||
? [['html', { open: 'never', outputFolder: 'test-results/html' }]]
|
||||
: undefined,
|
||||
use: {
|
||||
userAgent: 'playwright',
|
||||
screenshot: 'on',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type FallbackProps,
|
||||
} from 'react-error-boundary';
|
||||
import { HotkeysProvider } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
send,
|
||||
} from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useMetadataPref } from '../hooks/useMetadataPref';
|
||||
import { installPolyfills } from '../polyfills';
|
||||
import { ResponsiveProvider } from '../ResponsiveProvider';
|
||||
import { styles, hasHiddenScrollbars, ThemeStyle } from '../style';
|
||||
@@ -33,7 +34,6 @@ import { DevelopmentTopBar } from './DevelopmentTopBar';
|
||||
import { FatalError } from './FatalError';
|
||||
import { FinancesApp } from './FinancesApp';
|
||||
import { ManagementApp } from './manager/ManagementApp';
|
||||
import { MobileWebMessage } from './mobile/MobileWebMessage';
|
||||
import { UpdateNotification } from './UpdateNotification';
|
||||
|
||||
type AppInnerProps = {
|
||||
@@ -42,6 +42,7 @@ type AppInnerProps = {
|
||||
};
|
||||
|
||||
function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [initializing, setInitializing] = useState(true);
|
||||
const { showBoundary: showErrorBoundary } = useErrorBoundary();
|
||||
const loadingText = useSelector((state: State) => state.app.loadingText);
|
||||
@@ -52,7 +53,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
|
||||
|
||||
dispatch(
|
||||
setAppState({
|
||||
loadingText: 'Initializing the connection to the local database...',
|
||||
loadingText: t('Initializing the connection to the local database...'),
|
||||
}),
|
||||
);
|
||||
await initConnection(socketName);
|
||||
@@ -60,7 +61,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
|
||||
// Load any global prefs
|
||||
dispatch(
|
||||
setAppState({
|
||||
loadingText: 'Loading global preferences...',
|
||||
loadingText: t('Loading global preferences...'),
|
||||
}),
|
||||
);
|
||||
await dispatch(loadGlobalPrefs());
|
||||
@@ -68,18 +69,20 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
|
||||
// Open the last opened budget, if any
|
||||
dispatch(
|
||||
setAppState({
|
||||
loadingText: 'Opening last budget...',
|
||||
loadingText: t('Opening last budget...'),
|
||||
}),
|
||||
);
|
||||
const budgetId = await send('get-last-opened-backup');
|
||||
if (budgetId) {
|
||||
await dispatch(loadBudget(budgetId, 'Loading the last budget file...'));
|
||||
await dispatch(
|
||||
loadBudget(budgetId, t('Loading the last budget file...')),
|
||||
);
|
||||
|
||||
// Check to see if this file has been remotely deleted (but
|
||||
// don't block on this in case they are offline or something)
|
||||
dispatch(
|
||||
setAppState({
|
||||
loadingText: 'Retrieving remote files...',
|
||||
loadingText: t('Retrieving remote files...'),
|
||||
}),
|
||||
);
|
||||
send('get-remote-files').then(files => {
|
||||
@@ -124,7 +127,6 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
|
||||
))}
|
||||
|
||||
<UpdateNotification />
|
||||
<MobileWebMessage />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -139,8 +141,8 @@ function ErrorFallback({ error }: FallbackProps) {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [budgetId] = useLocalPref('id');
|
||||
const [cloudFileId] = useLocalPref('cloudFileId');
|
||||
const [budgetId] = useMetadataPref('id');
|
||||
const [cloudFileId] = useMetadataPref('cloudFileId');
|
||||
const [hiddenScrollbars, setHiddenScrollbars] = useState(
|
||||
hasHiddenScrollbars(),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, type ReactNode } from 'react';
|
||||
import { LazyLoadFailedError } from 'loot-core/src/shared/errors';
|
||||
|
||||
import { Block } from './common/Block';
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Link } from './common/Link';
|
||||
import { Modal } from './common/Modal';
|
||||
import { Paragraph } from './common/Paragraph';
|
||||
@@ -149,8 +149,8 @@ function SharedArrayBufferOverride() {
|
||||
I understand the risks, run Actual in the unsupported fallback mode
|
||||
</label>
|
||||
<Button
|
||||
disabled={!understand}
|
||||
onClick={() => {
|
||||
isDisabled={!understand}
|
||||
onPress={() => {
|
||||
window.localStorage.setItem('SharedArrayBufferOverride', 'true');
|
||||
window.location.reload();
|
||||
}}
|
||||
@@ -191,7 +191,7 @@ export function FatalError({ error }: FatalErrorProps) {
|
||||
)}
|
||||
|
||||
<Paragraph>
|
||||
<Button onClick={() => window.Actual?.relaunch()}>Restart app</Button>
|
||||
<Button onPress={() => window.Actual?.relaunch()}>Restart app</Button>
|
||||
</Paragraph>
|
||||
<Paragraph isLast={true} style={{ fontSize: 11 }}>
|
||||
<Link variant="text" onClick={() => setShowError(state => !state)}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { type State } from 'loot-core/src/client/state-types';
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { theme, styles, type CSSProperties } from '../style';
|
||||
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Menu } from './common/Menu';
|
||||
import { Popover } from './common/Popover';
|
||||
import { Text } from './common/Text';
|
||||
@@ -97,8 +97,8 @@ export function LoggedInUser({
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', ...style }}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
variant="bare"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={color && { color }}
|
||||
>
|
||||
{serverMessage()}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { pushModal } from 'loot-core/src/client/actions/modals';
|
||||
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import * as undo from 'loot-core/src/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
import { mapField, friendlyOp } from 'loot-core/src/shared/rules';
|
||||
import { describeSchedule } from 'loot-core/src/shared/schedules';
|
||||
import { type NewRuleEntity } from 'loot-core/src/types/models';
|
||||
@@ -23,7 +24,7 @@ import { usePayees } from '../hooks/usePayees';
|
||||
import { useSelected, SelectedProvider } from '../hooks/useSelected';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Link } from './common/Link';
|
||||
import { Search } from './common/Search';
|
||||
import { Stack } from './common/Stack';
|
||||
@@ -78,6 +79,11 @@ function ruleToString(rule, data) {
|
||||
data.payees.find(p => p.id === schedule._payee),
|
||||
),
|
||||
];
|
||||
} else if (action.op === 'prepend-notes' || action.op === 'append-notes') {
|
||||
return [
|
||||
friendlyOp(action.op),
|
||||
'“' + mapValue(action.field, action.value, data) + '”',
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
@@ -125,9 +131,9 @@ function ManageRulesContent({
|
||||
(filter === ''
|
||||
? allRules
|
||||
: allRules.filter(rule =>
|
||||
ruleToString(rule, filterData)
|
||||
.toLowerCase()
|
||||
.includes(filter.toLowerCase()),
|
||||
getNormalisedString(ruleToString(rule, filterData)).includes(
|
||||
getNormalisedString(filter),
|
||||
),
|
||||
)
|
||||
).slice(0, 100 + page * 50),
|
||||
[allRules, filter, filterData, page],
|
||||
@@ -313,11 +319,11 @@ function ManageRulesContent({
|
||||
>
|
||||
<Stack direction="row" align="center" justify="flex-end" spacing={2}>
|
||||
{selectedInst.items.size > 0 && (
|
||||
<Button onClick={onDeleteSelected}>
|
||||
<Button onPress={onDeleteSelected}>
|
||||
Delete {selectedInst.items.size} rules
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" onClick={onCreateRule}>
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
Create new rule
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import { closeModal } from 'loot-core/client/actions';
|
||||
import { type PopModalAction } from 'loot-core/src/client/state-types/modals';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useModalState } from '../hooks/useModalState';
|
||||
import { useSyncServerStatus } from '../hooks/useSyncServerStatus';
|
||||
|
||||
import { ModalTitle } from './common/Modal';
|
||||
import { ModalTitle, ModalHeader } from './common/Modal2';
|
||||
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
|
||||
import { AccountMenuModal } from './modals/AccountMenuModal';
|
||||
import { BudgetListModal } from './modals/BudgetListModal';
|
||||
@@ -71,67 +71,43 @@ export type CommonModalProps = {
|
||||
};
|
||||
|
||||
export function Modals() {
|
||||
const modalStack = useSelector((state: State) => state.modals.modalStack);
|
||||
const isHidden = useSelector((state: State) => state.modals.isHidden);
|
||||
const actions = useActions();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { modalStack } = useModalState();
|
||||
|
||||
useEffect(() => {
|
||||
if (modalStack.length > 0) {
|
||||
actions.closeModal();
|
||||
dispatch(closeModal());
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
const syncServerStatus = useSyncServerStatus();
|
||||
|
||||
const modals = modalStack
|
||||
.map(({ name, options }, idx) => {
|
||||
const modalProps: CommonModalProps = {
|
||||
onClose: actions.popModal,
|
||||
onBack: actions.popModal,
|
||||
showBack: idx > 0,
|
||||
isCurrent: idx === modalStack.length - 1,
|
||||
isHidden,
|
||||
stackIndex: idx,
|
||||
};
|
||||
|
||||
.map(({ name, options }) => {
|
||||
switch (name) {
|
||||
case 'keyboard-shortcuts':
|
||||
return <KeyboardShortcutModal modalProps={modalProps} />;
|
||||
return <KeyboardShortcutModal />;
|
||||
|
||||
case 'import-transactions':
|
||||
return (
|
||||
<ImportTransactions
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
return <ImportTransactions key={name} options={options} />;
|
||||
|
||||
case 'add-account':
|
||||
return (
|
||||
<CreateAccountModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
syncServerStatus={syncServerStatus}
|
||||
upgradingAccountId={options?.upgradingAccountId}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'add-local-account':
|
||||
return (
|
||||
<CreateLocalAccountModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
return <CreateLocalAccountModal key={name} />;
|
||||
|
||||
case 'close-account':
|
||||
return (
|
||||
<CloseAccountModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
account={options.account}
|
||||
balance={options.balance}
|
||||
canDelete={options.canDelete}
|
||||
@@ -142,10 +118,8 @@ export function Modals() {
|
||||
return (
|
||||
<SelectLinkedAccounts
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
externalAccounts={options.accounts}
|
||||
requisitionId={options.requisitionId}
|
||||
actions={actions}
|
||||
syncSource={options.syncSource}
|
||||
/>
|
||||
);
|
||||
@@ -154,7 +128,6 @@ export function Modals() {
|
||||
return (
|
||||
<ConfirmCategoryDelete
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
category={options.category}
|
||||
group={options.group}
|
||||
onDelete={options.onDelete}
|
||||
@@ -165,7 +138,6 @@ export function Modals() {
|
||||
return (
|
||||
<ConfirmUnlinkAccount
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
accountName={options.accountName}
|
||||
onUnlink={options.onUnlink}
|
||||
/>
|
||||
@@ -175,7 +147,6 @@ export function Modals() {
|
||||
return (
|
||||
<ConfirmTransactionEdit
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onCancel={options.onCancel}
|
||||
onConfirm={options.onConfirm}
|
||||
confirmReason={options.confirmReason}
|
||||
@@ -186,7 +157,7 @@ export function Modals() {
|
||||
return (
|
||||
<ConfirmTransactionDelete
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
message={options.message}
|
||||
onConfirm={options.onConfirm}
|
||||
/>
|
||||
);
|
||||
@@ -197,26 +168,17 @@ export function Modals() {
|
||||
key={name}
|
||||
watchUpdates
|
||||
budgetId={options.budgetId}
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
backupDisabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'manage-rules':
|
||||
return (
|
||||
<ManageRulesModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
payeeId={options?.payeeId}
|
||||
/>
|
||||
);
|
||||
return <ManageRulesModal key={name} payeeId={options?.payeeId} />;
|
||||
|
||||
case 'edit-rule':
|
||||
return (
|
||||
<EditRule
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
defaultRule={options.rule}
|
||||
onSave={options.onSave}
|
||||
/>
|
||||
@@ -226,7 +188,6 @@ export function Modals() {
|
||||
return (
|
||||
<MergeUnusedPayees
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
payeeIds={options.payeeIds}
|
||||
targetPayeeId={options.targetPayeeId}
|
||||
/>
|
||||
@@ -234,27 +195,18 @@ export function Modals() {
|
||||
|
||||
case 'gocardless-init':
|
||||
return (
|
||||
<GoCardlessInitialise
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onSuccess={options.onSuccess}
|
||||
/>
|
||||
<GoCardlessInitialise key={name} onSuccess={options.onSuccess} />
|
||||
);
|
||||
|
||||
case 'simplefin-init':
|
||||
return (
|
||||
<SimpleFinInitialise
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onSuccess={options.onSuccess}
|
||||
/>
|
||||
<SimpleFinInitialise key={name} onSuccess={options.onSuccess} />
|
||||
);
|
||||
|
||||
case 'gocardless-external-msg':
|
||||
return (
|
||||
<GoCardlessExternalMsg
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onMoveExternal={options.onMoveExternal}
|
||||
onClose={() => {
|
||||
options.onClose?.();
|
||||
@@ -265,28 +217,15 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'create-encryption-key':
|
||||
return (
|
||||
<CreateEncryptionKeyModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
return <CreateEncryptionKeyModal key={name} options={options} />;
|
||||
|
||||
case 'fix-encryption-key':
|
||||
return (
|
||||
<FixEncryptionKeyModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
return <FixEncryptionKeyModal key={name} options={options} />;
|
||||
|
||||
case 'edit-field':
|
||||
return (
|
||||
<EditField
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
name={options.name}
|
||||
onSubmit={options.onSubmit}
|
||||
onClose={options.onClose}
|
||||
@@ -297,7 +236,6 @@ export function Modals() {
|
||||
return (
|
||||
<CategoryAutocompleteModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
autocompleteProps={{
|
||||
value: null,
|
||||
onSelect: options.onSelect,
|
||||
@@ -313,7 +251,6 @@ export function Modals() {
|
||||
return (
|
||||
<AccountAutocompleteModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
autocompleteProps={{
|
||||
value: null,
|
||||
onSelect: options.onSelect,
|
||||
@@ -327,7 +264,6 @@ export function Modals() {
|
||||
return (
|
||||
<PayeeAutocompleteModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
autocompleteProps={{
|
||||
value: null,
|
||||
onSelect: options.onSelect,
|
||||
@@ -340,8 +276,13 @@ export function Modals() {
|
||||
return (
|
||||
<SingleInputModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
title={<ModalTitle title="New Category" shrinkOnOverflow />}
|
||||
name={name}
|
||||
Header={props => (
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={<ModalTitle title="New Category" shrinkOnOverflow />}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder="Category name"
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
@@ -353,8 +294,15 @@ export function Modals() {
|
||||
return (
|
||||
<SingleInputModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
title={<ModalTitle title="New Category Group" shrinkOnOverflow />}
|
||||
name={name}
|
||||
Header={props => (
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={
|
||||
<ModalTitle title="New Category Group" shrinkOnOverflow />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder="Category group name"
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
@@ -370,7 +318,6 @@ export function Modals() {
|
||||
>
|
||||
<RolloverBudgetSummaryModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
month={options.month}
|
||||
onBudgetAction={options.onBudgetAction}
|
||||
/>
|
||||
@@ -378,21 +325,13 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'report-budget-summary':
|
||||
return (
|
||||
<ReportBudgetSummaryModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
month={options.month}
|
||||
/>
|
||||
);
|
||||
return <ReportBudgetSummaryModal key={name} month={options.month} />;
|
||||
|
||||
case 'schedule-edit':
|
||||
return (
|
||||
<ScheduleDetails
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
id={options?.id || null}
|
||||
actions={actions}
|
||||
transaction={options?.transaction || null}
|
||||
/>
|
||||
);
|
||||
@@ -401,36 +340,23 @@ export function Modals() {
|
||||
return (
|
||||
<ScheduleLink
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
transactionIds={options?.transactionIds}
|
||||
getTransaction={options?.getTransaction}
|
||||
accountName={options?.accountName}
|
||||
onScheduleLinked={options?.onScheduleLinked}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'schedules-discover':
|
||||
return (
|
||||
<DiscoverSchedules
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
return <DiscoverSchedules key={name} />;
|
||||
|
||||
case 'schedule-posts-offline-notification':
|
||||
return (
|
||||
<PostsOfflineNotification
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
return <PostsOfflineNotification key={name} />;
|
||||
|
||||
case 'account-menu':
|
||||
return (
|
||||
<AccountMenuModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
accountId={options.accountId}
|
||||
onSave={options.onSave}
|
||||
onEditNotes={options.onEditNotes}
|
||||
@@ -444,11 +370,11 @@ export function Modals() {
|
||||
return (
|
||||
<CategoryMenuModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
categoryId={options.categoryId}
|
||||
onSave={options.onSave}
|
||||
onEditNotes={options.onEditNotes}
|
||||
onDelete={options.onDelete}
|
||||
onToggleVisibility={options.onToggleVisibility}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
);
|
||||
@@ -460,7 +386,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<RolloverBudgetMenuModal
|
||||
modalProps={modalProps}
|
||||
categoryId={options.categoryId}
|
||||
onUpdateBudget={options.onUpdateBudget}
|
||||
onCopyLastMonthAverage={options.onCopyLastMonthAverage}
|
||||
@@ -477,7 +402,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<ReportBudgetMenuModal
|
||||
modalProps={modalProps}
|
||||
categoryId={options.categoryId}
|
||||
onUpdateBudget={options.onUpdateBudget}
|
||||
onCopyLastMonthAverage={options.onCopyLastMonthAverage}
|
||||
@@ -491,13 +415,13 @@ export function Modals() {
|
||||
return (
|
||||
<CategoryGroupMenuModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
groupId={options.groupId}
|
||||
onSave={options.onSave}
|
||||
onAddCategory={options.onAddCategory}
|
||||
onEditNotes={options.onEditNotes}
|
||||
onSaveNotes={options.onSaveNotes}
|
||||
onDelete={options.onDelete}
|
||||
onToggleVisibility={options.onToggleVisibility}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
);
|
||||
@@ -506,7 +430,6 @@ export function Modals() {
|
||||
return (
|
||||
<NotesModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
id={options.id}
|
||||
name={options.name}
|
||||
onSave={options.onSave}
|
||||
@@ -520,7 +443,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<RolloverBalanceMenuModal
|
||||
modalProps={modalProps}
|
||||
categoryId={options.categoryId}
|
||||
onCarryover={options.onCarryover}
|
||||
onTransfer={options.onTransfer}
|
||||
@@ -536,7 +458,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<RolloverToBudgetMenuModal
|
||||
modalProps={modalProps}
|
||||
onTransfer={options.onTransfer}
|
||||
onCover={options.onCover}
|
||||
onHoldBuffer={options.onHoldBuffer}
|
||||
@@ -552,7 +473,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<HoldBufferModal
|
||||
modalProps={modalProps}
|
||||
month={options.month}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
@@ -566,7 +486,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<ReportBalanceMenuModal
|
||||
modalProps={modalProps}
|
||||
categoryId={options.categoryId}
|
||||
onCarryover={options.onCarryover}
|
||||
/>
|
||||
@@ -577,7 +496,6 @@ export function Modals() {
|
||||
return (
|
||||
<TransferModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
title={options.title}
|
||||
month={options.month}
|
||||
amount={options.amount}
|
||||
@@ -590,10 +508,10 @@ export function Modals() {
|
||||
return (
|
||||
<CoverModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
title={options.title}
|
||||
month={options.month}
|
||||
showToBeBudgeted={options.showToBeBudgeted}
|
||||
category={options.category}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
);
|
||||
@@ -602,7 +520,6 @@ export function Modals() {
|
||||
return (
|
||||
<ScheduledTransactionMenuModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
transactionId={options.transactionId}
|
||||
onPost={options.onPost}
|
||||
onSkip={options.onSkip}
|
||||
@@ -613,7 +530,6 @@ export function Modals() {
|
||||
return (
|
||||
<BudgetPageMenuModal
|
||||
key={name}
|
||||
modalProps={modalProps}
|
||||
onAddCategoryGroup={options.onAddCategoryGroup}
|
||||
onToggleHiddenCategories={options.onToggleHiddenCategories}
|
||||
onSwitchBudgetFile={options.onSwitchBudgetFile}
|
||||
@@ -627,7 +543,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<RolloverBudgetMonthMenuModal
|
||||
modalProps={modalProps}
|
||||
month={options.month}
|
||||
onBudgetAction={options.onBudgetAction}
|
||||
onEditNotes={options.onEditNotes}
|
||||
@@ -642,7 +557,6 @@ export function Modals() {
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<ReportBudgetMonthMenuModal
|
||||
modalProps={modalProps}
|
||||
month={options.month}
|
||||
onBudgetAction={options.onBudgetAction}
|
||||
onEditNotes={options.onEditNotes}
|
||||
@@ -651,7 +565,7 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'budget-list':
|
||||
return <BudgetListModal key={name} modalProps={modalProps} />;
|
||||
return <BudgetListModal key={name} />;
|
||||
|
||||
default:
|
||||
console.error('Unknown modal:', name);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useNotes } from '../hooks/useNotes';
|
||||
import { SvgCustomNotesPaper } from '../icons/v2';
|
||||
import { type CSSProperties, theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Popover } from './common/Popover';
|
||||
import { Tooltip } from './common/Tooltip';
|
||||
import { View } from './common/View';
|
||||
@@ -52,7 +52,7 @@ export function NotesButton({
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="View notes"
|
||||
className={!hasNotes && !isOpen ? 'hover-visible' : ''}
|
||||
style={{
|
||||
@@ -61,8 +61,7 @@ export function NotesButton({
|
||||
...(hasNotes && { display: 'flex !important' }),
|
||||
...(isOpen && { color: theme.buttonNormalText }),
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation();
|
||||
onPress={() => {
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -5,18 +5,18 @@ import React, {
|
||||
useMemo,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { removeNotification } from 'loot-core/client/actions';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import type { NotificationWithId } from 'loot-core/src/client/state-types/notifications';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { AnimatedLoading } from '../icons/AnimatedLoading';
|
||||
import { SvgDelete } from '../icons/v0';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { styles, theme, type CSSProperties } from '../style';
|
||||
|
||||
import { Button, ButtonWithLoading } from './common/Button';
|
||||
import { Button, ButtonWithLoading } from './common/Button2';
|
||||
import { Link } from './common/Link';
|
||||
import { Stack } from './common/Stack';
|
||||
import { Text } from './common/Text';
|
||||
@@ -120,6 +120,11 @@ function Notification({
|
||||
[message, messageActions],
|
||||
);
|
||||
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle: CSSProperties = isNarrowWidth
|
||||
? { minHeight: styles.mobileMinHeight }
|
||||
: {};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -133,10 +138,11 @@ function Notification({
|
||||
>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="space-between"
|
||||
direction="row"
|
||||
style={{
|
||||
padding: '14px 14px',
|
||||
fontSize: 14,
|
||||
...styles.mediumText,
|
||||
backgroundColor: positive
|
||||
? theme.noticeBackgroundLight
|
||||
: error
|
||||
@@ -156,7 +162,15 @@ function Notification({
|
||||
>
|
||||
<Stack align="flex-start">
|
||||
{title && (
|
||||
<View style={{ fontWeight: 700, marginBottom: 10 }}>{title}</View>
|
||||
<View
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
fontWeight: 700,
|
||||
marginBottom: 10,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</View>
|
||||
)}
|
||||
<View>{processedMessage}</View>
|
||||
{pre
|
||||
@@ -178,15 +192,15 @@ function Notification({
|
||||
: null}
|
||||
{button && (
|
||||
<ButtonWithLoading
|
||||
type="bare"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
variant="bare"
|
||||
isLoading={loading}
|
||||
onPress={async () => {
|
||||
setLoading(true);
|
||||
await button.action();
|
||||
onRemove();
|
||||
setLoading(false);
|
||||
}}
|
||||
style={{
|
||||
style={({ isHovered, isPressed }) => ({
|
||||
backgroundColor: 'transparent',
|
||||
border: `1px solid ${
|
||||
positive
|
||||
@@ -196,31 +210,32 @@ function Notification({
|
||||
: theme.warningBorder
|
||||
}`,
|
||||
color: 'currentColor',
|
||||
fontSize: 14,
|
||||
...styles.mediumText,
|
||||
flexShrink: 0,
|
||||
'&:hover, &:active': {
|
||||
backgroundColor: positive
|
||||
? theme.noticeBackground
|
||||
: error
|
||||
? theme.errorBackground
|
||||
: theme.warningBackground,
|
||||
},
|
||||
}}
|
||||
...(isHovered || isPressed
|
||||
? {
|
||||
backgroundColor: positive
|
||||
? theme.noticeBackground
|
||||
: error
|
||||
? theme.errorBackground
|
||||
: theme.warningBackground,
|
||||
}
|
||||
: {}),
|
||||
...narrowStyle,
|
||||
})}
|
||||
>
|
||||
{button.title}
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
</Stack>
|
||||
{sticky && (
|
||||
<Button
|
||||
type="bare"
|
||||
aria-label="Close"
|
||||
style={{ flexShrink: 0, color: 'currentColor' }}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label="Close"
|
||||
style={{ flexShrink: 0, color: 'currentColor' }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
<SvgDelete style={{ width: 9, height: 9, color: 'currentColor' }} />
|
||||
</Button>
|
||||
</Stack>
|
||||
{overlayLoading && (
|
||||
<View
|
||||
@@ -245,18 +260,22 @@ function Notification({
|
||||
}
|
||||
|
||||
export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
const { removeNotification } = useActions();
|
||||
const dispatch = useDispatch();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const notifications = useSelector(
|
||||
(state: State) => state.notifications.notifications,
|
||||
);
|
||||
const notificationInset = useSelector(
|
||||
(state: State) => state.notifications.inset,
|
||||
);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 20,
|
||||
right: 13,
|
||||
left: isNarrowWidth ? 13 : undefined,
|
||||
bottom: notificationInset?.bottom || 20,
|
||||
top: notificationInset?.top,
|
||||
right: notificationInset?.right || 13,
|
||||
left: notificationInset?.left || (isNarrowWidth ? 13 : undefined),
|
||||
zIndex: 10000,
|
||||
...style,
|
||||
}}
|
||||
@@ -269,7 +288,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
if (note.onClose) {
|
||||
note.onClose();
|
||||
}
|
||||
removeNotification(note.id);
|
||||
dispatch(removeNotification(note.id));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { type CSSProperties, themeOptions, useTheme } from '../style';
|
||||
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Menu } from './common/Menu';
|
||||
import { Popover } from './common/Popover';
|
||||
|
||||
@@ -44,9 +44,9 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
<>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Switch theme"
|
||||
onClick={() => setMenuOpen(true)}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={style}
|
||||
>
|
||||
<Icon style={{ width: 13, height: 13, color: 'inherit' }} />
|
||||
|
||||
@@ -9,8 +9,9 @@ import { isDevelopmentEnvironment } from 'loot-core/src/shared/environment';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useGlobalPref } from '../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useMetadataPref } from '../hooks/useMetadataPref';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
import { useSyncedPref } from '../hooks/useSyncedPref';
|
||||
import { SvgArrowLeft } from '../icons/v1';
|
||||
import {
|
||||
SvgAlertTriangle,
|
||||
@@ -24,7 +25,7 @@ import { theme, type CSSProperties, styles } from '../style';
|
||||
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
|
||||
import { AnimatedRefresh } from './AnimatedRefresh';
|
||||
import { MonthCountSelector } from './budget/MonthCountSelector';
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Link } from './common/Link';
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
@@ -60,15 +61,27 @@ type PrivacyButtonProps = {
|
||||
|
||||
function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
const [isPrivacyEnabled, setPrivacyEnabledPref] =
|
||||
useLocalPref('isPrivacyEnabled');
|
||||
useSyncedPref('isPrivacyEnabled');
|
||||
|
||||
const privacyIconStyle = { width: 15, height: 15 };
|
||||
|
||||
useHotkeys(
|
||||
'shift+ctrl+p, shift+cmd+p, shift+meta+p',
|
||||
() => {
|
||||
setPrivacyEnabledPref(!isPrivacyEnabled);
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[setPrivacyEnabledPref, isPrivacyEnabled],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label={`${isPrivacyEnabled ? 'Disable' : 'Enable'} privacy mode`}
|
||||
onClick={() => setPrivacyEnabledPref(!isPrivacyEnabled)}
|
||||
onPress={() => setPrivacyEnabledPref(!isPrivacyEnabled)}
|
||||
style={style}
|
||||
>
|
||||
{isPrivacyEnabled ? (
|
||||
@@ -85,7 +98,7 @@ type SyncButtonProps = {
|
||||
isMobile?: boolean;
|
||||
};
|
||||
function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
const [cloudFileId] = useLocalPref('cloudFileId');
|
||||
const [cloudFileId] = useMetadataPref('cloudFileId');
|
||||
const { sync } = useActions();
|
||||
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
@@ -184,10 +197,10 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Sync"
|
||||
style={
|
||||
isMobile
|
||||
style={({ isHovered, isPressed }) => ({
|
||||
...(isMobile
|
||||
? {
|
||||
...style,
|
||||
WebkitAppRegion: 'none',
|
||||
@@ -197,11 +210,11 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
...style,
|
||||
WebkitAppRegion: 'none',
|
||||
color: desktopColor,
|
||||
}
|
||||
}
|
||||
hoveredStyle={hoveredStyle}
|
||||
activeStyle={activeStyle}
|
||||
onClick={sync}
|
||||
}),
|
||||
...(isHovered ? hoveredStyle : {}),
|
||||
...(isPressed ? activeStyle : {}),
|
||||
})}
|
||||
onPress={sync}
|
||||
>
|
||||
{isMobile ? (
|
||||
syncState === 'error' ? (
|
||||
@@ -269,14 +282,15 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
>
|
||||
{(floatingSidebar || sidebar.alwaysFloats) && (
|
||||
<Button
|
||||
type="bare"
|
||||
aria-label="Sidebar menu"
|
||||
variant="bare"
|
||||
style={{ marginRight: 8 }}
|
||||
onPointerEnter={e => {
|
||||
onHoverStart={e => {
|
||||
if (e.pointerType === 'mouse') {
|
||||
sidebar.setHidden(false);
|
||||
}
|
||||
}}
|
||||
onPointerUp={e => {
|
||||
onPress={e => {
|
||||
if (e.pointerType !== 'mouse') {
|
||||
sidebar.setHidden(!sidebar.hidden);
|
||||
}
|
||||
@@ -294,7 +308,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
path="/accounts"
|
||||
element={
|
||||
location.state?.goBack ? (
|
||||
<Button type="bare" onClick={() => navigate(-1)}>
|
||||
<Button variant="bare" onPress={() => navigate(-1)}>
|
||||
<SvgArrowLeft
|
||||
width={10}
|
||||
height={10}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useActions } from '../hooks/useActions';
|
||||
import { SvgClose } from '../icons/v1';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button';
|
||||
import { Button } from './common/Button2';
|
||||
import { Link } from './common/Link';
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
@@ -72,10 +72,10 @@ export function UpdateNotification() {
|
||||
</Link>
|
||||
)
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Close"
|
||||
style={{ display: 'inline', padding: '1px 7px 2px 7px' }}
|
||||
onClick={() => {
|
||||
onPress={() => {
|
||||
// Set a flag to never show an update notification again for this session
|
||||
setAppState({
|
||||
updateInfo: null,
|
||||
|
||||
@@ -15,11 +15,9 @@ import * as queries from 'loot-core/src/client/queries';
|
||||
import { runQuery, pagedQuery } from 'loot-core/src/client/query-helpers';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
import { currentDay } from 'loot-core/src/shared/months';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { q } from 'loot-core/src/shared/query';
|
||||
import { getScheduledAmount } from 'loot-core/src/shared/schedules';
|
||||
import {
|
||||
deleteTransaction,
|
||||
updateTransaction,
|
||||
realizeTempTransactions,
|
||||
ungroupTransaction,
|
||||
@@ -34,7 +32,6 @@ import { useActions } from '../../hooks/useActions';
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { useFailedAccounts } from '../../hooks/useFailedAccounts';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { usePayees } from '../../hooks/usePayees';
|
||||
import { usePreviewTransactions } from '../../hooks/usePreviewTransactions';
|
||||
import { SelectedProviderWithItems } from '../../hooks/useSelected';
|
||||
@@ -42,6 +39,8 @@ import {
|
||||
SplitsExpandedProvider,
|
||||
useSplitsExpanded,
|
||||
} from '../../hooks/useSplitsExpanded';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { useTransactionBatchActions } from '../../hooks/useTransactionBatchActions';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -76,7 +75,12 @@ function EmptyMessage({ onAdd }) {
|
||||
manage it locally yourself.
|
||||
</Text>
|
||||
|
||||
<Button variant="primary" style={{ marginTop: 20 }} onPress={onAdd}>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ marginTop: 20 }}
|
||||
autoFocus
|
||||
onPress={onAdd}
|
||||
>
|
||||
Add account
|
||||
</Button>
|
||||
|
||||
@@ -97,12 +101,15 @@ function AllTransactions({
|
||||
showBalances,
|
||||
filtered,
|
||||
children,
|
||||
collapseTransactions,
|
||||
}) {
|
||||
const accountId = account.id;
|
||||
const prependTransactions = usePreviewTransactions().map(trans => ({
|
||||
...trans,
|
||||
_inverse: accountId ? accountId !== trans.account : false,
|
||||
}));
|
||||
const prependTransactions = usePreviewTransactions(collapseTransactions).map(
|
||||
trans => ({
|
||||
...trans,
|
||||
_inverse: accountId ? accountId !== trans.account : false,
|
||||
}),
|
||||
);
|
||||
|
||||
transactions ??= [];
|
||||
|
||||
@@ -112,7 +119,7 @@ function AllTransactions({
|
||||
}
|
||||
|
||||
return balances && transactions?.length > 0
|
||||
? balances[transactions[0].id]?.balance ?? 0
|
||||
? (balances[transactions[0].id]?.balance ?? 0)
|
||||
: 0;
|
||||
}, [showBalances, balances, transactions]);
|
||||
|
||||
@@ -818,241 +825,26 @@ class AccountInternal extends PureComponent {
|
||||
});
|
||||
};
|
||||
|
||||
onBatchEdit = async (name, ids) => {
|
||||
const { data } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*')
|
||||
.options({ splits: 'grouped' }),
|
||||
);
|
||||
const transactions = ungroupTransactions(data);
|
||||
onBatchEdit = (name, ids) => {
|
||||
this.props.onBatchEdit({
|
||||
name,
|
||||
ids,
|
||||
onSuccess: updatedIds => {
|
||||
this.refetchTransactions();
|
||||
|
||||
const onChange = async (name, value, mode) => {
|
||||
let transactionsToChange = transactions;
|
||||
|
||||
const newValue = value === null ? '' : value;
|
||||
this.setState({ workingHard: true });
|
||||
|
||||
const changes = { deleted: [], updated: [] };
|
||||
|
||||
// Cleared is a special case right now
|
||||
if (name === 'cleared') {
|
||||
// Clear them if any are uncleared, otherwise unclear them
|
||||
value = !!transactionsToChange.find(t => !t.cleared);
|
||||
}
|
||||
|
||||
const idSet = new Set(ids);
|
||||
|
||||
transactionsToChange.forEach(trans => {
|
||||
if (name === 'cleared' && trans.reconciled) {
|
||||
// Skip transactions that are reconciled. Don't want to set them as
|
||||
// uncleared.
|
||||
return;
|
||||
if (this.table.current) {
|
||||
this.table.current.edit(updatedIds[0], 'select', false);
|
||||
}
|
||||
|
||||
if (!idSet.has(trans.id)) {
|
||||
// Skip transactions which aren't actually selected, since the query
|
||||
// above also retrieves the siblings & parent of any selected splits.
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'notes') {
|
||||
if (mode === 'prepend') {
|
||||
value =
|
||||
trans.notes === null ? newValue : newValue + ' ' + trans.notes;
|
||||
} else if (mode === 'append') {
|
||||
value =
|
||||
trans.notes === null ? newValue : trans.notes + ' ' + newValue;
|
||||
} else if (mode === 'replace') {
|
||||
value = newValue;
|
||||
}
|
||||
}
|
||||
const transaction = {
|
||||
...trans,
|
||||
[name]: value,
|
||||
};
|
||||
|
||||
if (name === 'account' && trans.account !== value) {
|
||||
transaction.reconciled = false;
|
||||
}
|
||||
|
||||
const { diff } = updateTransaction(transactionsToChange, transaction);
|
||||
|
||||
// TODO: We need to keep an updated list of transactions so
|
||||
// the logic in `updateTransaction`, particularly about
|
||||
// updating split transactions, works. This isn't ideal and we
|
||||
// should figure something else out
|
||||
transactionsToChange = applyChanges(diff, transactionsToChange);
|
||||
|
||||
changes.deleted = changes.deleted
|
||||
? changes.deleted.concat(diff.deleted)
|
||||
: diff.deleted;
|
||||
changes.updated = changes.updated
|
||||
? changes.updated.concat(diff.updated)
|
||||
: diff.updated;
|
||||
changes.added = changes.added
|
||||
? changes.added.concat(diff.added)
|
||||
: diff.added;
|
||||
});
|
||||
|
||||
await send('transactions-batch-update', changes);
|
||||
await this.refetchTransactions();
|
||||
|
||||
if (this.table.current) {
|
||||
this.table.current.edit(transactionsToChange[0].id, 'select', false);
|
||||
}
|
||||
};
|
||||
|
||||
const pushPayeeAutocompleteModal = () => {
|
||||
this.props.pushModal('payee-autocomplete', {
|
||||
onSelect: payeeId => onChange(name, payeeId),
|
||||
});
|
||||
};
|
||||
|
||||
const pushAccountAutocompleteModal = () => {
|
||||
this.props.pushModal('account-autocomplete', {
|
||||
onSelect: accountId => onChange(name, accountId),
|
||||
});
|
||||
};
|
||||
|
||||
const pushCategoryAutocompleteModal = () => {
|
||||
// Only show balances when all selected transaction are in the same month.
|
||||
const transactionMonth = transactions[0]?.date
|
||||
? monthUtils.monthFromDate(transactions[0]?.date)
|
||||
: null;
|
||||
const transactionsHaveSameMonth =
|
||||
transactionMonth &&
|
||||
transactions.every(
|
||||
t => monthUtils.monthFromDate(t.date) === transactionMonth,
|
||||
);
|
||||
this.props.pushModal('category-autocomplete', {
|
||||
month: transactionsHaveSameMonth ? transactionMonth : undefined,
|
||||
onSelect: categoryId => onChange(name, categoryId),
|
||||
});
|
||||
};
|
||||
|
||||
if (
|
||||
name === 'amount' ||
|
||||
name === 'payee' ||
|
||||
name === 'account' ||
|
||||
name === 'date'
|
||||
) {
|
||||
const reconciledTransactions = transactions.filter(t => t.reconciled);
|
||||
if (reconciledTransactions.length > 0) {
|
||||
this.props.pushModal('confirm-transaction-edit', {
|
||||
onConfirm: () => {
|
||||
if (name === 'payee') {
|
||||
pushPayeeAutocompleteModal();
|
||||
} else if (name === 'account') {
|
||||
pushAccountAutocompleteModal();
|
||||
} else {
|
||||
this.props.pushModal('edit-field', { name, onSubmit: onChange });
|
||||
}
|
||||
},
|
||||
confirmReason: 'batchEditWithReconciled',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'cleared') {
|
||||
// Cleared just toggles it on/off and it depends on the data
|
||||
// loaded. Need to clean this up in the future.
|
||||
onChange('cleared', null);
|
||||
} else if (name === 'category') {
|
||||
pushCategoryAutocompleteModal();
|
||||
} else if (name === 'payee') {
|
||||
pushPayeeAutocompleteModal();
|
||||
} else if (name === 'account') {
|
||||
pushAccountAutocompleteModal();
|
||||
} else {
|
||||
this.props.pushModal('edit-field', { name, onSubmit: onChange });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onBatchDuplicate = async ids => {
|
||||
const onConfirmDuplicate = async ids => {
|
||||
this.setState({ workingHard: true });
|
||||
|
||||
const { data } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*')
|
||||
.options({ splits: 'grouped' }),
|
||||
);
|
||||
|
||||
const changes = {
|
||||
added: data
|
||||
.reduce((newTransactions, trans) => {
|
||||
return newTransactions.concat(
|
||||
realizeTempTransactions(ungroupTransaction(trans)),
|
||||
);
|
||||
}, [])
|
||||
.map(({ sort_order, ...trans }) => ({ ...trans })),
|
||||
};
|
||||
|
||||
await send('transactions-batch-update', changes);
|
||||
|
||||
await this.refetchTransactions();
|
||||
};
|
||||
|
||||
await this.checkForReconciledTransactions(
|
||||
ids,
|
||||
'batchDuplicateWithReconciled',
|
||||
onConfirmDuplicate,
|
||||
);
|
||||
onBatchDuplicate = ids => {
|
||||
this.props.onBatchDuplicate({ ids, onSuccess: this.refetchTransactions });
|
||||
};
|
||||
|
||||
onBatchDelete = async ids => {
|
||||
const onConfirmDelete = async ids => {
|
||||
this.setState({ workingHard: true });
|
||||
|
||||
const { data } = await runQuery(
|
||||
q('transactions')
|
||||
.filter({ id: { $oneof: ids } })
|
||||
.select('*')
|
||||
.options({ splits: 'grouped' }),
|
||||
);
|
||||
let transactions = ungroupTransactions(data);
|
||||
|
||||
const idSet = new Set(ids);
|
||||
const changes = { deleted: [], updated: [] };
|
||||
|
||||
transactions.forEach(trans => {
|
||||
const parentId = trans.parent_id;
|
||||
|
||||
// First, check if we're actually deleting this transaction by
|
||||
// checking `idSet`. Then, we don't need to do anything if it's
|
||||
// a child transaction and the parent is already being deleted
|
||||
if (!idSet.has(trans.id) || (parentId && idSet.has(parentId))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { diff } = deleteTransaction(transactions, trans.id);
|
||||
|
||||
// TODO: We need to keep an updated list of transactions so
|
||||
// the logic in `updateTransaction`, particularly about
|
||||
// updating split transactions, works. This isn't ideal and we
|
||||
// should figure something else out
|
||||
transactions = applyChanges(diff, transactions);
|
||||
|
||||
changes.deleted = diff.deleted
|
||||
? changes.deleted.concat(diff.deleted)
|
||||
: diff.deleted;
|
||||
changes.updated = diff.updated
|
||||
? changes.updated.concat(diff.updated)
|
||||
: diff.updated;
|
||||
});
|
||||
|
||||
await send('transactions-batch-update', changes);
|
||||
await this.refetchTransactions();
|
||||
};
|
||||
|
||||
await this.checkForReconciledTransactions(
|
||||
ids,
|
||||
'batchDeleteWithReconciled',
|
||||
onConfirmDelete,
|
||||
);
|
||||
onBatchDelete = ids => {
|
||||
this.props.onBatchDelete({ ids, onSuccess: this.refetchTransactions });
|
||||
};
|
||||
|
||||
onMakeAsSplitTransaction = async ids => {
|
||||
@@ -1183,12 +975,19 @@ class AccountInternal extends PureComponent {
|
||||
}
|
||||
};
|
||||
|
||||
onBatchUnlink = async ids => {
|
||||
await send('transactions-batch-update', {
|
||||
updated: ids.map(id => ({ id, schedule: null })),
|
||||
onBatchLinkSchedule = ids => {
|
||||
this.props.onBatchLinkSchedule({
|
||||
ids,
|
||||
account: this.props.accounts.find(a => a.id === this.props.accountId),
|
||||
onSuccess: this.refetchTransactions,
|
||||
});
|
||||
};
|
||||
|
||||
await this.refetchTransactions();
|
||||
onBatchUnlinkSchedule = ids => {
|
||||
this.props.onBatchUnlinkSchedule({
|
||||
ids,
|
||||
onSuccess: this.refetchTransactions,
|
||||
});
|
||||
};
|
||||
|
||||
onCreateRule = async ids => {
|
||||
@@ -1314,10 +1113,10 @@ class AccountInternal extends PureComponent {
|
||||
);
|
||||
};
|
||||
|
||||
onConditionsOpChange = (value, conditions) => {
|
||||
onConditionsOpChange = value => {
|
||||
this.setState({ filterConditionsOp: value });
|
||||
this.setState({ filterId: { ...this.state.filterId, status: 'changed' } });
|
||||
this.applyFilters([...conditions]);
|
||||
this.applyFilters([...this.state.filterConditions]);
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
}
|
||||
@@ -1657,6 +1456,9 @@ class AccountInternal extends PureComponent {
|
||||
balances={balances}
|
||||
showBalances={showBalances}
|
||||
filtered={transactionsFiltered}
|
||||
collapseTransactions={ids =>
|
||||
this.props.splitsExpandedDispatch({ type: 'close-splits', ids })
|
||||
}
|
||||
>
|
||||
{(allTransactions, allBalances) => (
|
||||
<SelectedProviderWithItems
|
||||
@@ -1714,7 +1516,8 @@ class AccountInternal extends PureComponent {
|
||||
onBatchDelete={this.onBatchDelete}
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onBatchEdit={this.onBatchEdit}
|
||||
onBatchUnlink={this.onBatchUnlink}
|
||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onUpdateFilter={this.onUpdateFilter}
|
||||
onClearFilters={this.onClearFilters}
|
||||
@@ -1802,10 +1605,22 @@ class AccountInternal extends PureComponent {
|
||||
|
||||
function AccountHack(props) {
|
||||
const { dispatch: splitsExpandedDispatch } = useSplitsExpanded();
|
||||
const {
|
||||
onBatchEdit,
|
||||
onBatchDuplicate,
|
||||
onBatchLinkSchedule,
|
||||
onBatchUnlinkSchedule,
|
||||
onBatchDelete,
|
||||
} = useTransactionBatchActions();
|
||||
|
||||
return (
|
||||
<AccountInternal
|
||||
splitsExpandedDispatch={splitsExpandedDispatch}
|
||||
onBatchEdit={onBatchEdit}
|
||||
onBatchDuplicate={onBatchDuplicate}
|
||||
onBatchLinkSchedule={onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onBatchDelete={onBatchDelete}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1824,12 +1639,12 @@ export function Account() {
|
||||
const payees = usePayees();
|
||||
const failedAccounts = useFailedAccounts();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const [hideFraction = false] = useLocalPref('hideFraction');
|
||||
const [expandSplits] = useLocalPref('expand-splits');
|
||||
const [showBalances] = useLocalPref(`show-balances-${params.id}`);
|
||||
const [hideCleared] = useLocalPref(`hide-cleared-${params.id}`);
|
||||
const [hideReconciled] = useLocalPref(`hide-reconciled-${params.id}`);
|
||||
const [showExtraBalances] = useLocalPref(
|
||||
const [hideFraction = false] = useSyncedPref('hideFraction');
|
||||
const [expandSplits] = useSyncedPref('expand-splits');
|
||||
const [showBalances] = useSyncedPref(`show-balances-${params.id}`);
|
||||
const [hideCleared] = useSyncedPref(`hide-cleared-${params.id}`);
|
||||
const [hideReconciled] = useSyncedPref(`hide-reconciled-${params.id}`);
|
||||
const [showExtraBalances] = useSyncedPref(
|
||||
`show-extra-balances-${params.id || 'all-accounts'}`,
|
||||
);
|
||||
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
|
||||
|
||||
@@ -39,7 +39,15 @@ function getErrorMessage(type, code) {
|
||||
return 'Your SimpleFIN Access Token is no longer valid. Please reset and generate a new token.';
|
||||
|
||||
case 'ACCOUNT_NEEDS_ATTENTION':
|
||||
return 'The account needs your attention at [SimpleFIN](https://beta-bridge.simplefin.org/auth/login).';
|
||||
return (
|
||||
<>
|
||||
The account needs your attention at{' '}
|
||||
<Link variant="external" to="https://bridge.simplefin.org/auth/login">
|
||||
SimpleFIN
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
}
|
||||
@@ -132,6 +140,7 @@ export function AccountSyncCheck() {
|
||||
<Button onPress={unlink}>Unlink</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
autoFocus
|
||||
onPress={reauth}
|
||||
style={{ marginLeft: 5 }}
|
||||
>
|
||||
|
||||
@@ -74,7 +74,8 @@ export function AccountHeader({
|
||||
onBatchDelete,
|
||||
onBatchDuplicate,
|
||||
onBatchEdit,
|
||||
onBatchUnlink,
|
||||
onBatchLinkSchedule,
|
||||
onBatchUnlinkSchedule,
|
||||
onCreateRule,
|
||||
onApplyFilter,
|
||||
onUpdateFilter,
|
||||
@@ -130,6 +131,33 @@ export function AccountHeader({
|
||||
},
|
||||
[searchInput],
|
||||
);
|
||||
useHotkeys(
|
||||
't',
|
||||
() => onAddTransaction(),
|
||||
{
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[onAddTransaction],
|
||||
);
|
||||
useHotkeys(
|
||||
'ctrl+i, cmd+i, meta+i',
|
||||
() => onImport(),
|
||||
{
|
||||
scopes: ['app'],
|
||||
},
|
||||
[onImport],
|
||||
);
|
||||
useHotkeys(
|
||||
'ctrl+b, cmd+b, meta+b',
|
||||
() => onSync(),
|
||||
{
|
||||
enabled: canSync && !isServerOffline,
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[onSync],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -310,12 +338,14 @@ export function AccountHeader({
|
||||
</View>
|
||||
) : (
|
||||
<SelectedTransactionsButton
|
||||
account={account}
|
||||
getTransaction={id => transactions.find(t => t.id === id)}
|
||||
onShow={onShowTransactions}
|
||||
onDuplicate={onBatchDuplicate}
|
||||
onDelete={onBatchDelete}
|
||||
onEdit={onBatchEdit}
|
||||
onUnlink={onBatchUnlink}
|
||||
onLinkSchedule={onBatchLinkSchedule}
|
||||
onUnlinkSchedule={onBatchUnlinkSchedule}
|
||||
onCreateRule={onCreateRule}
|
||||
onSetTransfer={onSetTransfer}
|
||||
onScheduleAction={onScheduleAction}
|
||||
@@ -352,7 +382,11 @@ export function AccountHeader({
|
||||
</Button>
|
||||
{account ? (
|
||||
<View>
|
||||
<MenuButton ref={triggerRef} onClick={() => setMenuOpen(true)} />
|
||||
<MenuButton
|
||||
aria-label="Account menu"
|
||||
ref={triggerRef}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
@@ -379,7 +413,11 @@ export function AccountHeader({
|
||||
</View>
|
||||
) : (
|
||||
<View>
|
||||
<MenuButton ref={triggerRef} onClick={() => setMenuOpen(true)} />
|
||||
<MenuButton
|
||||
aria-label="Account menu"
|
||||
ref={triggerRef}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
@@ -44,6 +45,7 @@ function AccountList({
|
||||
renderAccountItemGroupHeader = defaultRenderAccountItemGroupHeader,
|
||||
renderAccountItem = defaultRenderAccountItem,
|
||||
}: AccountListProps) {
|
||||
const { t } = useTranslation();
|
||||
let lastItem = null;
|
||||
|
||||
return (
|
||||
@@ -63,10 +65,10 @@ function AccountList({
|
||||
|
||||
const group = `${
|
||||
item.closed
|
||||
? 'Closed Accounts'
|
||||
? t('Closed Accounts')
|
||||
: item.offbudget
|
||||
? 'Off Budget'
|
||||
: 'For Budget'
|
||||
? t('Off Budget')
|
||||
: t('For Budget')
|
||||
}`;
|
||||
|
||||
lastItem = item;
|
||||
|
||||
@@ -14,10 +14,12 @@ import React, {
|
||||
import Downshift, { type StateChangeTypes } from 'downshift';
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
|
||||
import { SvgRemove } from '../../icons/v2';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Button } from '../common/Button';
|
||||
import { Input } from '../common/Input';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
@@ -92,16 +94,8 @@ export function defaultFilterSuggestion<T extends Item>(
|
||||
suggestion: T,
|
||||
value: string,
|
||||
) {
|
||||
return getItemName(suggestion)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.includes(
|
||||
value
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/\p{Diacritic}/gu, ''),
|
||||
);
|
||||
const name = getItemName(suggestion);
|
||||
return getNormalisedString(name).includes(getNormalisedString(value));
|
||||
}
|
||||
|
||||
function defaultFilterSuggestions<T extends Item>(
|
||||
@@ -621,12 +615,7 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label="Remove autocomplete item"
|
||||
style={{ marginLeft: 1 }}
|
||||
onPress={onRemove}
|
||||
>
|
||||
<Button type="bare" style={{ marginLeft: 1 }} onClick={onRemove}>
|
||||
<SvgRemove style={{ width: 8, height: 8 }} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -9,21 +9,24 @@ import React, {
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { reportBudget, rolloverBudget } from 'loot-core/client/queries';
|
||||
import { integerToCurrency } from 'loot-core/shared/util';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
import {
|
||||
type CategoryEntity,
|
||||
type CategoryGroupEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { SvgSplit } from '../../icons/v0';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
|
||||
import { makeAmountFullStyle } from '../budget/util';
|
||||
import { Text } from '../common/Text';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
@@ -69,6 +72,7 @@ function CategoryList({
|
||||
showHiddenItems,
|
||||
showBalances,
|
||||
}: CategoryListProps) {
|
||||
const { t } = useTranslation();
|
||||
let lastGroup: string | undefined | null = null;
|
||||
|
||||
const filteredItems = useMemo(
|
||||
@@ -99,7 +103,7 @@ function CategoryList({
|
||||
}
|
||||
|
||||
const showGroup = item.cat_group !== lastGroup;
|
||||
const groupName = `${item.group?.name}${item.group?.hidden ? ' (hidden)' : ''}`;
|
||||
const groupName = `${item.group?.name}${item.group?.hidden ? ' ' + t('(hidden)') : ''}`;
|
||||
lastGroup = item.cat_group;
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
@@ -137,8 +141,8 @@ function CategoryList({
|
||||
}
|
||||
|
||||
function customSort(obj: CategoryAutocompleteItem, value: string): number {
|
||||
const name = obj.name.toLowerCase();
|
||||
const groupName = obj.group ? obj.group.name.toLowerCase() : '';
|
||||
const name = getNormalisedString(obj.name);
|
||||
const groupName = obj.group ? getNormalisedString(obj.group.name) : '';
|
||||
if (obj.id === 'split') {
|
||||
return -2;
|
||||
}
|
||||
@@ -206,21 +210,27 @@ export function CategoryAutocomplete({
|
||||
): CategoryAutocompleteItem[] => {
|
||||
return suggestions
|
||||
.filter(suggestion => {
|
||||
return (
|
||||
suggestion.id === 'split' ||
|
||||
suggestion.group?.name
|
||||
.toLowerCase()
|
||||
.includes(value.toLowerCase()) ||
|
||||
(suggestion.group?.name + ' ' + suggestion.name)
|
||||
.toLowerCase()
|
||||
.includes(value.toLowerCase()) ||
|
||||
defaultFilterSuggestion(suggestion, value)
|
||||
);
|
||||
if (suggestion.id === 'split') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (suggestion.group) {
|
||||
return (
|
||||
getNormalisedString(suggestion.group.name).includes(
|
||||
getNormalisedString(value),
|
||||
) ||
|
||||
getNormalisedString(
|
||||
suggestion.group.name + ' ' + suggestion.name,
|
||||
).includes(getNormalisedString(value))
|
||||
);
|
||||
}
|
||||
|
||||
return defaultFilterSuggestion(suggestion, value);
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
customSort(a, value.toLowerCase()) -
|
||||
customSort(b, value.toLowerCase()),
|
||||
customSort(a, getNormalisedString(value)) -
|
||||
customSort(b, getNormalisedString(value)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
@@ -331,7 +341,7 @@ function SplitTransactionButton({
|
||||
<SvgSplit width={10} height={10} style={{ marginRight: 5 }} />
|
||||
)}
|
||||
</Text>
|
||||
Split Transaction
|
||||
<Trans>Split Transaction</Trans>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -360,6 +370,7 @@ function CategoryItem({
|
||||
showBalances,
|
||||
...props
|
||||
}: CategoryItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
@@ -368,16 +379,19 @@ function CategoryItem({
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
}
|
||||
: {};
|
||||
const [budgetType] = useLocalPref('budgetType');
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
|
||||
const balance = useSheetValue(
|
||||
const balanceBinding =
|
||||
budgetType === 'rollover'
|
||||
? rolloverBudget.catBalance(item.id)
|
||||
: reportBudget.catBalance(item.id),
|
||||
);
|
||||
: reportBudget.catBalance(item.id);
|
||||
const balance = useSheetValue<
|
||||
'rollover-budget' | 'report-budget',
|
||||
typeof balanceBinding
|
||||
>(balanceBinding);
|
||||
|
||||
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
|
||||
const toBudget = useSheetValue(rolloverBudget.toBudget);
|
||||
const toBudget = useRolloverSheetValue(rolloverBudget.toBudget) ?? 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -405,7 +419,7 @@ function CategoryItem({
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
|
||||
<TextOneLine>
|
||||
{item.name}
|
||||
{item.hidden ? ' (hidden)' : null}
|
||||
{item.hidden ? ' ' + t('(hidden)') : null}
|
||||
</TextOneLine>
|
||||
<TextOneLine
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { theme } from '../../style/theme';
|
||||
import { View } from '../common/View';
|
||||
@@ -16,6 +17,7 @@ export function FilterList<T extends { id: string; name: string }>({
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
@@ -25,7 +27,7 @@ export function FilterList<T extends { id: string; name: string }>({
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<ItemHeader title="Saved Filters" type="filter" />
|
||||
<ItemHeader title={t('Saved Filters')} type="filter" />
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
|
||||
@@ -10,23 +10,25 @@ import React, {
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { createPayee } from 'loot-core/src/client/actions/queries';
|
||||
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
import {
|
||||
type AccountEntity,
|
||||
type PayeeEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { usePayees } from '../../hooks/usePayees';
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
import { useCommonPayees, usePayees } from '../../hooks/usePayees';
|
||||
import { SvgAdd, SvgBookmark } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Button } from '../common/Button';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
|
||||
@@ -39,11 +41,48 @@ import { ItemHeader } from './ItemHeader';
|
||||
|
||||
type PayeeAutocompleteItem = PayeeEntity;
|
||||
|
||||
const MAX_AUTO_SUGGESTIONS = 5;
|
||||
|
||||
function getPayeeSuggestions(
|
||||
commonPayees: PayeeAutocompleteItem[],
|
||||
payees: PayeeAutocompleteItem[],
|
||||
): (PayeeAutocompleteItem & PayeeItemType)[] {
|
||||
if (commonPayees?.length > 0) {
|
||||
const favoritePayees = payees.filter(p => p.favorite);
|
||||
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
|
||||
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
|
||||
additionalCommonPayees = commonPayees
|
||||
.filter(
|
||||
p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)),
|
||||
)
|
||||
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length);
|
||||
}
|
||||
const frequentPayees: (PayeeAutocompleteItem & PayeeItemType)[] =
|
||||
favoritePayees.concat(additionalCommonPayees).map(p => {
|
||||
return { ...p, itemType: 'common_payee' };
|
||||
});
|
||||
|
||||
const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
|
||||
.filter(p => !frequentPayees.find(fp => fp.id === p.id))
|
||||
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
|
||||
return { ...p, itemType: determineItemType(p, false) };
|
||||
});
|
||||
|
||||
return frequentPayees
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.concat(filteredPayees);
|
||||
}
|
||||
|
||||
return payees.map(p => {
|
||||
return { ...p, itemType: determineItemType(p, false) };
|
||||
});
|
||||
}
|
||||
|
||||
function filterActivePayees(
|
||||
payees: PayeeAutocompleteItem[],
|
||||
focusTransferPayees: boolean,
|
||||
accounts: AccountEntity[],
|
||||
): PayeeAutocompleteItem[] {
|
||||
) {
|
||||
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
|
||||
|
||||
if (focusTransferPayees && activePayees) {
|
||||
@@ -70,7 +109,8 @@ function stripNew(value) {
|
||||
}
|
||||
|
||||
type PayeeListProps = {
|
||||
items: PayeeAutocompleteItem[];
|
||||
items: (PayeeAutocompleteItem & PayeeItemType)[];
|
||||
commonPayees: PayeeEntity[];
|
||||
getItemProps: (arg: {
|
||||
item: PayeeAutocompleteItem;
|
||||
}) => ComponentProps<typeof View>;
|
||||
@@ -89,6 +129,25 @@ type PayeeListProps = {
|
||||
footer: ReactNode;
|
||||
};
|
||||
|
||||
type ItemTypes = 'account' | 'payee' | 'common_payee';
|
||||
type PayeeItemType = {
|
||||
itemType: ItemTypes;
|
||||
};
|
||||
|
||||
function determineItemType(
|
||||
item: PayeeAutocompleteItem,
|
||||
isCommon: boolean,
|
||||
): ItemTypes {
|
||||
if (item.transfer_acct) {
|
||||
return 'account';
|
||||
}
|
||||
if (isCommon) {
|
||||
return 'common_payee';
|
||||
} else {
|
||||
return 'payee';
|
||||
}
|
||||
}
|
||||
|
||||
function PayeeList({
|
||||
items,
|
||||
getItemProps,
|
||||
@@ -100,6 +159,8 @@ function PayeeList({
|
||||
renderPayeeItem = defaultRenderPayeeItem,
|
||||
footer,
|
||||
}: PayeeListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
let createNew = null;
|
||||
items = [...items];
|
||||
|
||||
@@ -133,16 +194,19 @@ function PayeeList({
|
||||
})}
|
||||
|
||||
{items.map((item, idx) => {
|
||||
const type = item.transfer_acct ? 'account' : 'payee';
|
||||
const itemType = item.itemType;
|
||||
let title;
|
||||
if (type === 'payee' && lastType !== type) {
|
||||
title = 'Payees';
|
||||
} else if (type === 'account' && lastType !== type) {
|
||||
title = 'Transfer To/From';
|
||||
|
||||
if (itemType === 'common_payee' && lastType !== itemType) {
|
||||
title = t('Suggested Payees');
|
||||
} else if (itemType === 'payee' && lastType !== itemType) {
|
||||
title = t('Payees');
|
||||
} else if (itemType === 'account' && lastType !== itemType) {
|
||||
title = t('Transfer To/From');
|
||||
}
|
||||
const showMoreMessage =
|
||||
idx === items.length - 1 && items.length > 100;
|
||||
lastType = type;
|
||||
lastType = itemType;
|
||||
|
||||
return (
|
||||
<Fragment key={item.id}>
|
||||
@@ -169,7 +233,7 @@ function PayeeList({
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
More payees are available, search to find them
|
||||
<Trans>More payees are available, search to find them</Trans>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
@@ -219,6 +283,7 @@ export function PayeeAutocomplete({
|
||||
payees,
|
||||
...props
|
||||
}: PayeeAutocompleteProps) {
|
||||
const commonPayees = useCommonPayees();
|
||||
const retrievedPayees = usePayees();
|
||||
if (!payees) {
|
||||
payees = retrievedPayees;
|
||||
@@ -233,17 +298,21 @@ export function PayeeAutocomplete({
|
||||
const [rawPayee, setRawPayee] = useState('');
|
||||
const hasPayeeInput = !!rawPayee;
|
||||
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
|
||||
const suggestions = getPayeeSuggestions(
|
||||
payees,
|
||||
const suggestions = getPayeeSuggestions(commonPayees, payees);
|
||||
const filteredSuggestions = filterActivePayees(
|
||||
suggestions,
|
||||
focusTransferPayees,
|
||||
accounts,
|
||||
);
|
||||
|
||||
if (!hasPayeeInput) {
|
||||
return suggestions;
|
||||
return filteredSuggestions;
|
||||
}
|
||||
return [{ id: 'new', name: '' }, ...suggestions];
|
||||
}, [payees, focusTransferPayees, accounts, hasPayeeInput]);
|
||||
filteredSuggestions.forEach(s => {
|
||||
console.log(s.name + ' ' + s.id);
|
||||
});
|
||||
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
|
||||
}, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -288,6 +357,7 @@ export function PayeeAutocomplete({
|
||||
focused={payeeFieldFocused}
|
||||
inputProps={{
|
||||
...inputProps,
|
||||
autoCapitalize: 'words',
|
||||
onBlur: () => {
|
||||
setRawPayee('');
|
||||
setPayeeFieldFocused(false);
|
||||
@@ -313,8 +383,12 @@ export function PayeeAutocomplete({
|
||||
});
|
||||
|
||||
filtered.sort((p1, p2) => {
|
||||
const r1 = p1.name.toLowerCase().startsWith(value.toLowerCase());
|
||||
const r2 = p2.name.toLowerCase().startsWith(value.toLowerCase());
|
||||
const r1 = getNormalisedString(p1.name).startsWith(
|
||||
getNormalisedString(value),
|
||||
);
|
||||
const r2 = getNormalisedString(p2.name).startsWith(
|
||||
getNormalisedString(value),
|
||||
);
|
||||
const r1exact = p1.name.toLowerCase() === value.toLowerCase();
|
||||
const r2exact = p2.name.toLowerCase() === value.toLowerCase();
|
||||
|
||||
@@ -355,6 +429,7 @@ export function PayeeAutocomplete({
|
||||
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
|
||||
<PayeeList
|
||||
items={items}
|
||||
commonPayees={commonPayees}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
inputValue={inputValue}
|
||||
@@ -366,24 +441,19 @@ export function PayeeAutocomplete({
|
||||
<AutocompleteFooter embedded={embedded}>
|
||||
{showMakeTransfer && (
|
||||
<Button
|
||||
variant={focusTransferPayees ? 'menuSelected' : 'menu'}
|
||||
aria-label="Make transfer"
|
||||
type={focusTransferPayees ? 'menuSelected' : 'menu'}
|
||||
style={showManagePayees && { marginBottom: 5 }}
|
||||
onPress={() => {
|
||||
onClick={() => {
|
||||
onUpdate?.(null, null);
|
||||
setFocusTransferPayees(!focusTransferPayees);
|
||||
}}
|
||||
>
|
||||
Make Transfer
|
||||
<Trans>Make Transfer</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{showManagePayees && (
|
||||
<Button
|
||||
variant="menu"
|
||||
aria-label="Manage payees"
|
||||
onPress={() => onManagePayees()}
|
||||
>
|
||||
Manage Payees
|
||||
<Button type="menu" onClick={() => onManagePayees()}>
|
||||
<Trans>Manage Payees</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</AutocompleteFooter>
|
||||
@@ -453,7 +523,7 @@ export function CreatePayeeButton({
|
||||
style={{ marginRight: 5, display: 'inline-block' }}
|
||||
/>
|
||||
)}
|
||||
Create Payee “{payeeName}”
|
||||
<Trans>Create Payee “{{ payeeName }}”</Trans>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -493,7 +563,19 @@ function PayeeItem({
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const iconSize = isNarrowWidth ? 14 : 8;
|
||||
let paddingLeftOverFromIcon = 20;
|
||||
let itemIcon = undefined;
|
||||
if (item.favorite) {
|
||||
itemIcon = (
|
||||
<SvgBookmark
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ marginRight: 5, display: 'inline-block' }}
|
||||
/>
|
||||
);
|
||||
paddingLeftOverFromIcon -= iconSize + 5;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
|
||||
@@ -528,7 +610,7 @@ function PayeeItem({
|
||||
: theme.menuAutoCompleteItemText,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
padding: 4,
|
||||
paddingLeft: 20,
|
||||
paddingLeft: paddingLeftOverFromIcon,
|
||||
...narrowStyle,
|
||||
},
|
||||
])}`}
|
||||
@@ -536,7 +618,10 @@ function PayeeItem({
|
||||
data-highlighted={highlighted || undefined}
|
||||
{...props}
|
||||
>
|
||||
<TextOneLine>{item.name}</TextOneLine>
|
||||
<TextOneLine>
|
||||
{itemIcon}
|
||||
{item.name}
|
||||
</TextOneLine>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ReportAutocomplete({
|
||||
embedded,
|
||||
...props
|
||||
}: ReportAutocompleteProps) {
|
||||
const reports = useReports() || [];
|
||||
const { data: reports } = useReports();
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { Fragment, type ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { theme } from '../../style/theme';
|
||||
import { View } from '../common/View';
|
||||
@@ -16,6 +17,7 @@ export function ReportList<T extends { id: string; name: string }>({
|
||||
highlightedIndex: number;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View>
|
||||
<View
|
||||
@@ -25,7 +27,7 @@ export function ReportList<T extends { id: string; name: string }>({
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
>
|
||||
<Fragment>{ItemHeader({ title: 'Saved Reports' })}</Fragment>
|
||||
<Fragment>{ItemHeader({ title: t('Saved Reports') })}</Fragment>
|
||||
{items.map((item, idx) => {
|
||||
return [
|
||||
<div
|
||||
|
||||
@@ -3,10 +3,12 @@ import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgArrowThinRight } from '../../icons/v1';
|
||||
import { type CSSProperties } from '../../style';
|
||||
import { type CSSProperties, theme, styles } from '../../style';
|
||||
import { Tooltip } from '../common/Tooltip';
|
||||
import { View } from '../common/View';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
import { CellValue } from '../spreadsheet/CellValue';
|
||||
import { useFormat } from '../spreadsheet/useFormat';
|
||||
import { useSheetValue } from '../spreadsheet/useSheetValue';
|
||||
|
||||
import { makeBalanceAmountStyle } from './util';
|
||||
@@ -19,10 +21,11 @@ type BalanceWithCarryoverProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof CellValue>,
|
||||
'binding'
|
||||
> & {
|
||||
carryover: Binding;
|
||||
balance: Binding;
|
||||
goal: Binding;
|
||||
budgeted: Binding;
|
||||
carryover: Binding<'rollover-budget', 'carryover'>;
|
||||
balance: Binding<'rollover-budget', 'leftover'>;
|
||||
goal: Binding<'rollover-budget', 'goal'>;
|
||||
budgeted: Binding<'rollover-budget', 'budget'>;
|
||||
longGoal: Binding<'rollover-budget', 'long-goal'>;
|
||||
disabled?: boolean;
|
||||
carryoverIndicator?: ({ style }: CarryoverIndicatorProps) => JSX.Element;
|
||||
};
|
||||
@@ -49,11 +52,27 @@ export function DefaultCarryoverIndicator({ style }: CarryoverIndicatorProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function GoalTooltipRow({ children }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BalanceWithCarryover({
|
||||
carryover,
|
||||
balance,
|
||||
goal,
|
||||
budgeted,
|
||||
longGoal,
|
||||
disabled,
|
||||
carryoverIndicator = DefaultCarryoverIndicator,
|
||||
...props
|
||||
@@ -62,11 +81,40 @@ export function BalanceWithCarryover({
|
||||
const balanceValue = useSheetValue(balance);
|
||||
const goalValue = useSheetValue(goal);
|
||||
const budgetedValue = useSheetValue(budgeted);
|
||||
const longGoalValue = useSheetValue(longGoal);
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const valueStyle = makeBalanceAmountStyle(
|
||||
balanceValue,
|
||||
isGoalTemplatesEnabled ? goalValue : null,
|
||||
budgetedValue,
|
||||
longGoalValue === 1 ? balanceValue : budgetedValue,
|
||||
);
|
||||
const format = useFormat();
|
||||
|
||||
const differenceToGoal =
|
||||
longGoalValue === 1 ? balanceValue - goalValue : budgetedValue - goalValue;
|
||||
|
||||
const balanceCellValue = (
|
||||
<CellValue
|
||||
{...props}
|
||||
binding={balance}
|
||||
type="financial"
|
||||
getStyle={value =>
|
||||
makeBalanceAmountStyle(
|
||||
value,
|
||||
isGoalTemplatesEnabled ? goalValue : null,
|
||||
longGoalValue === 1 ? balanceValue : budgetedValue,
|
||||
)
|
||||
}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'right',
|
||||
...(!disabled && {
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -78,27 +126,55 @@ export function BalanceWithCarryover({
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<CellValue
|
||||
{...props}
|
||||
binding={balance}
|
||||
type="financial"
|
||||
getStyle={value =>
|
||||
makeBalanceAmountStyle(
|
||||
value,
|
||||
isGoalTemplatesEnabled ? goalValue : null,
|
||||
budgetedValue,
|
||||
)
|
||||
}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
textAlign: 'right',
|
||||
...(!disabled && {
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
{isGoalTemplatesEnabled && goalValue !== null ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<View style={{ padding: 10 }}>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
{differenceToGoal === 0 ? (
|
||||
<span style={{ color: theme.noticeText }}>Fully funded</span>
|
||||
) : differenceToGoal > 0 ? (
|
||||
<span style={{ color: theme.noticeText }}>
|
||||
Overfunded ({format(differenceToGoal, 'financial')})
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: theme.errorText }}>
|
||||
Underfunded ({format(differenceToGoal, 'financial')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<GoalTooltipRow>
|
||||
<div>Goal Type:</div>
|
||||
<div>{longGoalValue === 1 ? 'Long' : 'Template'}</div>
|
||||
</GoalTooltipRow>
|
||||
<GoalTooltipRow>
|
||||
<div>Goal:</div>
|
||||
<div>{format(goalValue, 'financial')}</div>
|
||||
</GoalTooltipRow>
|
||||
<GoalTooltipRow>
|
||||
{longGoalValue !== 1 ? (
|
||||
<>
|
||||
<div>Budgeted:</div>
|
||||
<div>{format(budgetedValue, 'financial')}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>Balance:</div>
|
||||
<div>{format(balanceValue, 'financial')}</div>
|
||||
</>
|
||||
)}
|
||||
</GoalTooltipRow>
|
||||
</View>
|
||||
}
|
||||
style={{ ...styles.tooltip, borderRadius: '0px 5px 5px 0px' }}
|
||||
placement="bottom"
|
||||
triggerProps={{ delay: 750 }}
|
||||
>
|
||||
{balanceCellValue}
|
||||
</Tooltip>
|
||||
) : (
|
||||
balanceCellValue
|
||||
)}
|
||||
{carryoverValue && carryoverIndicator({ style: valueStyle })}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ComponentProps, memo } from 'react';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { MonthPicker } from './MonthPicker';
|
||||
@@ -17,18 +15,6 @@ type BudgetPageHeaderProps = {
|
||||
|
||||
export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
|
||||
({ startMonth, onMonthSelect, numMonths, monthBounds }) => {
|
||||
function getValidMonth(month) {
|
||||
const start = monthBounds.start;
|
||||
const end = monthUtils.subMonths(monthBounds.end, numMonths - 1);
|
||||
|
||||
if (month < start) {
|
||||
return start;
|
||||
} else if (month > end) {
|
||||
return end;
|
||||
}
|
||||
return month;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ marginLeft: 200 + 5, flexShrink: 0 }}>
|
||||
<View style={{ marginRight: 5 + getScrollbarWidth() }}>
|
||||
@@ -37,7 +23,7 @@ export const BudgetPageHeader = memo<BudgetPageHeaderProps>(
|
||||
numDisplayed={numMonths}
|
||||
monthBounds={monthBounds}
|
||||
style={{ paddingTop: 5 }}
|
||||
onSelect={month => onMonthSelect(getValidMonth(month))}
|
||||
onSelect={month => onMonthSelect(month)}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -9,7 +9,12 @@ import { BudgetCategories } from './BudgetCategories';
|
||||
import { BudgetSummaries } from './BudgetSummaries';
|
||||
import { BudgetTotals } from './BudgetTotals';
|
||||
import { MonthsProvider } from './MonthsContext';
|
||||
import { findSortDown, findSortUp, getScrollbarWidth } from './util';
|
||||
import {
|
||||
findSortDown,
|
||||
findSortUp,
|
||||
getScrollbarWidth,
|
||||
separateGroups,
|
||||
} from './util';
|
||||
|
||||
export function BudgetTable(props) {
|
||||
const {
|
||||
@@ -86,9 +91,10 @@ export function BudgetTable(props) {
|
||||
};
|
||||
|
||||
const _onReorderGroup = (id, dropPos, targetId) => {
|
||||
const [expenseGroups] = separateGroups(categoryGroups); // exclude Income group from sortable groups to fix off-by-one error
|
||||
onReorderGroup({
|
||||
id,
|
||||
...findSortDown(categoryGroups, dropPos, targetId),
|
||||
...findSortDown(expenseGroups, dropPos, targetId),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { type ComponentProps, memo, useRef, useState } from 'react';
|
||||
|
||||
import { SvgDotsHorizontalTriple } from '../../icons/v1';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
@@ -57,11 +57,9 @@ export const BudgetTotals = memo(function BudgetTotals({
|
||||
<View style={{ flexGrow: '1' }}>Category</View>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
variant="bare"
|
||||
aria-label="Menu"
|
||||
onClick={() => {
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={{ color: 'currentColor', padding: 3 }}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect, type ComponentProps } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { useBudgetMonthCount } from './BudgetMonthCountContext';
|
||||
@@ -32,6 +35,7 @@ type DynamicBudgetTableInnerProps = {
|
||||
} & DynamicBudgetTableProps;
|
||||
|
||||
const DynamicBudgetTableInner = ({
|
||||
type,
|
||||
width,
|
||||
height,
|
||||
prewarmStartMonth,
|
||||
@@ -51,10 +55,65 @@ const DynamicBudgetTableInner = ({
|
||||
setDisplayMax(numPossible);
|
||||
}, [numPossible]);
|
||||
|
||||
function _onMonthSelect(month) {
|
||||
onMonthSelect(month, numMonths);
|
||||
function getValidMonth(month) {
|
||||
const start = monthBounds.start;
|
||||
const end = monthUtils.subMonths(monthBounds.end, numMonths - 1);
|
||||
|
||||
if (month < start) {
|
||||
return start;
|
||||
} else if (month > end) {
|
||||
return end;
|
||||
}
|
||||
return month;
|
||||
}
|
||||
|
||||
function _onMonthSelect(month) {
|
||||
onMonthSelect(getValidMonth(month), numMonths);
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
'left',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.prevMonth(startMonth));
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[_onMonthSelect, startMonth],
|
||||
);
|
||||
useHotkeys(
|
||||
'right',
|
||||
() => {
|
||||
_onMonthSelect(monthUtils.nextMonth(startMonth));
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[_onMonthSelect, startMonth],
|
||||
);
|
||||
useHotkeys(
|
||||
'0',
|
||||
() => {
|
||||
_onMonthSelect(
|
||||
monthUtils.subMonths(
|
||||
monthUtils.currentMonth(),
|
||||
type === 'rollover'
|
||||
? Math.floor((numMonths - 1) / 2)
|
||||
: numMonths === 2
|
||||
? 1
|
||||
: Math.max(numMonths - 2, 0),
|
||||
),
|
||||
);
|
||||
},
|
||||
{
|
||||
preventDefault: true,
|
||||
scopes: ['app'],
|
||||
},
|
||||
[_onMonthSelect, startMonth, numMonths],
|
||||
);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { View } from '../common/View';
|
||||
|
||||
import { RenderMonths } from './RenderMonths';
|
||||
@@ -23,7 +23,7 @@ export function IncomeHeader({
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Button onClick={onShowNewGroup} style={{ fontSize: 12, margin: 10 }}>
|
||||
<Button onPress={onShowNewGroup} style={{ fontSize: 12, margin: 10 }}>
|
||||
Add Group
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -133,6 +133,17 @@ export const MonthPicker = ({
|
||||
!selected && {
|
||||
backgroundColor: theme.buttonBareBackgroundHover,
|
||||
}),
|
||||
...(!hovered &&
|
||||
!selected &&
|
||||
current && {
|
||||
backgroundColor: theme.buttonBareBackgroundHover,
|
||||
filter: 'brightness(120%)',
|
||||
}),
|
||||
...(hovered &&
|
||||
selected &&
|
||||
current && {
|
||||
filter: 'brightness(120%)',
|
||||
}),
|
||||
...(hovered &&
|
||||
selected && {
|
||||
backgroundColor: theme.tableBorderHover,
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
@@ -57,6 +57,7 @@ export function SidebarCategory({
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
opacity: category.hidden || categoryGroup?.hidden ? 0.33 : undefined,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -72,13 +73,10 @@ export function SidebarCategory({
|
||||
</div>
|
||||
<View style={{ flexShrink: 0, marginLeft: 5 }} ref={triggerRef}>
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
className="hover-visible"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
style={{ color: 'currentColor', padding: 3 }}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
>
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type ConnectDragSource } from 'react-dnd';
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -94,12 +94,9 @@ export function SidebarGroup({
|
||||
<>
|
||||
<View style={{ marginLeft: 5, flexShrink: 0 }} ref={triggerRef}>
|
||||
<Button
|
||||
type="bare"
|
||||
variant="bare"
|
||||
className="hover-visible"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={{ padding: 3 }}
|
||||
>
|
||||
<SvgCheveronDown width={14} height={14} />
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useCategories } from '../../hooks/useCategories';
|
||||
import { useGlobalPref } from '../../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { styles } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
|
||||
@@ -75,8 +76,7 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
start: startMonth,
|
||||
end: startMonth,
|
||||
});
|
||||
const [budgetTypePref] = useLocalPref('budgetType');
|
||||
const budgetType = budgetTypePref || 'rollover';
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
const [maxMonthsPref] = useGlobalPref('maxMonths');
|
||||
const maxMonths = maxMonthsPref || 1;
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { reportBudget } from 'loot-core/src/client/queries';
|
||||
|
||||
import { Menu } from '../../common/Menu';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
|
||||
import { useReportSheetValue } from './ReportComponents';
|
||||
|
||||
type BalanceMenuProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof Menu>,
|
||||
@@ -18,7 +20,8 @@ export function BalanceMenu({
|
||||
onCarryover,
|
||||
...props
|
||||
}: BalanceMenuProps) {
|
||||
const carryover = useSheetValue(reportBudget.catCarryover(categoryId));
|
||||
const { t } = useTranslation();
|
||||
const carryover = useReportSheetValue(reportBudget.catCarryover(categoryId));
|
||||
return (
|
||||
<Menu
|
||||
{...props}
|
||||
@@ -35,8 +38,8 @@ export function BalanceMenu({
|
||||
{
|
||||
name: 'carryover',
|
||||
text: carryover
|
||||
? 'Remove overspending rollover'
|
||||
: 'Rollover overspending',
|
||||
? t('Remove overspending rollover')
|
||||
: t('Rollover overspending'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { Menu } from '../../common/Menu';
|
||||
@@ -17,6 +18,7 @@ export function BudgetMenu({
|
||||
onApplyBudgetTemplate,
|
||||
...props
|
||||
}: BudgetMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
const onMenuSelect = (name: string) => {
|
||||
switch (name) {
|
||||
@@ -47,25 +49,25 @@ export function BudgetMenu({
|
||||
items={[
|
||||
{
|
||||
name: 'copy-single-last',
|
||||
text: 'Copy last month’s budget',
|
||||
text: t('Copy last month’s budget'),
|
||||
},
|
||||
{
|
||||
name: 'set-single-3-avg',
|
||||
text: 'Set to 3 month average',
|
||||
text: t('Set to 3 month average'),
|
||||
},
|
||||
{
|
||||
name: 'set-single-6-avg',
|
||||
text: 'Set to 6 month average',
|
||||
text: t('Set to 6 month average'),
|
||||
},
|
||||
{
|
||||
name: 'set-single-12-avg',
|
||||
text: 'Set to yearly average',
|
||||
text: t('Set to yearly average'),
|
||||
},
|
||||
...(isGoalTemplatesEnabled
|
||||
? [
|
||||
{
|
||||
name: 'apply-single-category-template',
|
||||
text: 'Apply budget template',
|
||||
text: t('Apply budget template'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { memo, useRef, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { reportBudget } from 'loot-core/src/client/queries';
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { SvgCheveronDown } from '../../../icons/v1';
|
||||
import { styles, theme, type CSSProperties } from '../../../style';
|
||||
import { Button } from '../../common/Button';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { CellValue } from '../../spreadsheet/CellValue';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import { CellValue, type CellValueProps } from '../../spreadsheet/CellValue';
|
||||
import { useFormat } from '../../spreadsheet/useFormat';
|
||||
import { Field, SheetCell } from '../../table';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
import { Field, SheetCell, type SheetCellProps } from '../../table';
|
||||
import { BalanceWithCarryover } from '../BalanceWithCarryover';
|
||||
import { makeAmountGrey } from '../util';
|
||||
|
||||
import { BalanceMenu } from './BalanceMenu';
|
||||
import { BudgetMenu } from './BudgetMenu';
|
||||
|
||||
export const useReportSheetValue = <
|
||||
FieldName extends SheetFields<'report-budget'>,
|
||||
>(
|
||||
binding: Binding<'report-budget', FieldName>,
|
||||
) => {
|
||||
return useSheetValue(binding);
|
||||
};
|
||||
|
||||
const ReportCellValue = <FieldName extends SheetFields<'report-budget'>>(
|
||||
props: CellValueProps<'report-budget', FieldName>,
|
||||
) => {
|
||||
return <CellValue {...props} />;
|
||||
};
|
||||
|
||||
const ReportSheetCell = <FieldName extends SheetFields<'report-budget'>>(
|
||||
props: SheetCellProps<'report-budget', FieldName>,
|
||||
) => {
|
||||
return <SheetCell {...props} />;
|
||||
};
|
||||
|
||||
const headerLabelStyle: CSSProperties = {
|
||||
flex: 1,
|
||||
padding: '0 5px',
|
||||
@@ -38,8 +62,10 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
|
||||
}}
|
||||
>
|
||||
<View style={headerLabelStyle}>
|
||||
<Text style={{ color: theme.pageTextLight }}>Budgeted</Text>
|
||||
<CellValue
|
||||
<Text style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Budgeted</Trans>
|
||||
</Text>
|
||||
<ReportCellValue
|
||||
binding={reportBudget.totalBudgetedExpense}
|
||||
type="financial"
|
||||
style={{ color: theme.pageTextLight, fontWeight: 600 }}
|
||||
@@ -49,16 +75,20 @@ export const BudgetTotalsMonth = memo(function BudgetTotalsMonth() {
|
||||
/>
|
||||
</View>
|
||||
<View style={headerLabelStyle}>
|
||||
<Text style={{ color: theme.pageTextLight }}>Spent</Text>
|
||||
<CellValue
|
||||
<Text style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Spent</Trans>
|
||||
</Text>
|
||||
<ReportCellValue
|
||||
binding={reportBudget.totalSpent}
|
||||
type="financial"
|
||||
style={{ color: theme.pageTextLight, fontWeight: 600 }}
|
||||
/>
|
||||
</View>
|
||||
<View style={headerLabelStyle}>
|
||||
<Text style={{ color: theme.pageTextLight }}>Balance</Text>
|
||||
<CellValue
|
||||
<Text style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Balance</Trans>
|
||||
</Text>
|
||||
<ReportCellValue
|
||||
binding={reportBudget.totalLeftover}
|
||||
type="financial"
|
||||
style={{ color: theme.pageTextLight, fontWeight: 600 }}
|
||||
@@ -78,24 +108,40 @@ export function IncomeHeaderMonth() {
|
||||
}}
|
||||
>
|
||||
<View style={headerLabelStyle}>
|
||||
<Text style={{ color: theme.pageTextLight }}>Budgeted</Text>
|
||||
<Text style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Budgeted</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={headerLabelStyle}>
|
||||
<Text style={{ color: theme.pageTextLight }}>Received</Text>
|
||||
<Text style={{ color: theme.pageTextLight }}>
|
||||
<Trans>Received</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type GroupMonthProps = {
|
||||
month: string;
|
||||
group: { id: string; is_income: boolean };
|
||||
};
|
||||
export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) {
|
||||
export const GroupMonth = memo(function GroupMonth({
|
||||
month,
|
||||
group,
|
||||
}: GroupMonthProps) {
|
||||
const { id } = group;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, flexDirection: 'row' }}>
|
||||
<SheetCell
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: monthUtils.isCurrentMonth(month)
|
||||
? theme.budgetHeaderCurrentMonth
|
||||
: theme.budgetHeaderOtherMonth,
|
||||
}}
|
||||
>
|
||||
<ReportSheetCell
|
||||
name="budgeted"
|
||||
width="flex"
|
||||
textAlign="right"
|
||||
@@ -105,7 +151,7 @@ export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) {
|
||||
type: 'financial',
|
||||
}}
|
||||
/>
|
||||
<SheetCell
|
||||
<ReportSheetCell
|
||||
name="spent"
|
||||
width="flex"
|
||||
textAlign="right"
|
||||
@@ -116,7 +162,7 @@ export const GroupMonth = memo(function GroupMonth({ group }: GroupMonthProps) {
|
||||
}}
|
||||
/>
|
||||
{!group.is_income && (
|
||||
<SheetCell
|
||||
<ReportSheetCell
|
||||
name="balance"
|
||||
width="flex"
|
||||
textAlign="right"
|
||||
@@ -163,11 +209,20 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
const [balanceMenuOpen, setBalanceMenuOpen] = useState(false);
|
||||
const triggerBalanceMenuRef = useRef(null);
|
||||
|
||||
const onMenuAction = (...args: Parameters<typeof onBudgetAction>) => {
|
||||
onBudgetAction(...args);
|
||||
setBalanceMenuOpen(false);
|
||||
setMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: monthUtils.isCurrentMonth(month)
|
||||
? theme.budgetCurrentMonth
|
||||
: theme.budgetOtherMonth,
|
||||
'& .hover-visible': {
|
||||
opacity: 0,
|
||||
transition: 'opacity .25s',
|
||||
@@ -200,11 +255,8 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
variant="bare"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={{
|
||||
padding: 3,
|
||||
}}
|
||||
@@ -225,10 +277,9 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
>
|
||||
<BudgetMenu
|
||||
onCopyLastMonthAverage={() => {
|
||||
onBudgetAction?.(month, 'copy-single-last', {
|
||||
onMenuAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
@@ -239,22 +290,20 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
return;
|
||||
}
|
||||
|
||||
onBudgetAction?.(month, `set-single-${numberOfMonths}-avg`, {
|
||||
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onBudgetAction?.(month, 'apply-single-category-template', {
|
||||
onMenuAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
)}
|
||||
<SheetCell
|
||||
<ReportSheetCell
|
||||
name="budget"
|
||||
exposed={editing}
|
||||
focused={editing}
|
||||
@@ -304,7 +353,7 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
data-testid="category-month-spent"
|
||||
onClick={() => onShowActivity(category.id, month)}
|
||||
>
|
||||
<CellValue
|
||||
<ReportCellValue
|
||||
binding={reportBudget.catSumAmount(category.id)}
|
||||
type="financial"
|
||||
getStyle={makeAmountGrey}
|
||||
@@ -336,6 +385,7 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
balance={reportBudget.catBalance(category.id)}
|
||||
goal={reportBudget.catGoal(category.id)}
|
||||
budgeted={reportBudget.catBudgeted(category.id)}
|
||||
longGoal={reportBudget.catLongGoal(category.id)}
|
||||
style={{
|
||||
':hover': { textDecoration: 'underline' },
|
||||
}}
|
||||
@@ -351,11 +401,10 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
<BalanceMenu
|
||||
categoryId={category.id}
|
||||
onCarryover={carryover => {
|
||||
onBudgetAction?.(month, 'carryover', {
|
||||
onMenuAction(month, 'carryover', {
|
||||
category: category.id,
|
||||
flag: carryover,
|
||||
});
|
||||
setBalanceMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type ComponentPropsWithoutRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFeatureFlag } from '../../../../hooks/useFeatureFlag';
|
||||
import { Menu } from '../../../common/Menu';
|
||||
@@ -24,6 +25,7 @@ export function BudgetMonthMenu({
|
||||
onOverwriteWithBudgetTemplates,
|
||||
...props
|
||||
}: BudgetMonthMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
return (
|
||||
<Menu
|
||||
@@ -51,25 +53,25 @@ export function BudgetMonthMenu({
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{ name: 'copy-last', text: 'Copy last month’s budget' },
|
||||
{ name: 'set-zero', text: 'Set budgets to zero' },
|
||||
{ name: 'copy-last', text: t('Copy last month’s budget') },
|
||||
{ name: 'set-zero', text: t('Set budgets to zero') },
|
||||
{
|
||||
name: 'set-3-avg',
|
||||
text: 'Set budgets to 3 month average',
|
||||
text: t('Set budgets to 3 month average'),
|
||||
},
|
||||
...(isGoalTemplatesEnabled
|
||||
? [
|
||||
{
|
||||
name: 'check-templates',
|
||||
text: 'Check templates',
|
||||
text: t('Check templates'),
|
||||
},
|
||||
{
|
||||
name: 'apply-goal-template',
|
||||
text: 'Apply budget template',
|
||||
text: t('Apply budget template'),
|
||||
},
|
||||
{
|
||||
name: 'overwrite-goal-template',
|
||||
text: 'Overwrite with budget template',
|
||||
text: t('Overwrite with budget template'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
@@ -8,7 +9,7 @@ import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { SvgDotsHorizontalTriple } from '../../../../icons/v1';
|
||||
import { SvgArrowButtonDown1, SvgArrowButtonUp1 } from '../../../../icons/v2';
|
||||
import { theme, styles } from '../../../../style';
|
||||
import { Button } from '../../../common/Button';
|
||||
import { Button } from '../../../common/Button2';
|
||||
import { Popover } from '../../../common/Popover';
|
||||
import { Stack } from '../../../common/Stack';
|
||||
import { View } from '../../../common/View';
|
||||
@@ -25,6 +26,7 @@ type BudgetSummaryProps = {
|
||||
month?: string;
|
||||
};
|
||||
export function BudgetSummary({ month }: BudgetSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
currentMonth,
|
||||
summaryCollapsed: collapsed,
|
||||
@@ -50,7 +52,10 @@ export function BudgetSummary({ month }: BudgetSummaryProps) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: theme.tableBackground,
|
||||
backgroundColor:
|
||||
month === currentMonth
|
||||
? theme.budgetCurrentMonth
|
||||
: theme.budgetOtherMonth,
|
||||
boxShadow: styles.cardShadow,
|
||||
borderRadius: 6,
|
||||
marginLeft: 0,
|
||||
@@ -84,10 +89,14 @@ export function BudgetSummary({ month }: BudgetSummaryProps) {
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="bare"
|
||||
aria-label={`${collapsed ? 'Expand' : 'Collapse'} month summary`}
|
||||
variant="bare"
|
||||
aria-label={
|
||||
collapsed
|
||||
? t('Expand month summary')
|
||||
: t('Collapse month summary')
|
||||
}
|
||||
className="hover-visible"
|
||||
onClick={onToggleSummaryCollapse}
|
||||
onPress={onToggleSummaryCollapse}
|
||||
>
|
||||
<ExpandOrCollapseIcon
|
||||
width={13}
|
||||
@@ -133,9 +142,9 @@ export function BudgetSummary({ month }: BudgetSummaryProps) {
|
||||
<View style={{ userSelect: 'none' }}>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
type="bare"
|
||||
aria-label="Menu"
|
||||
onClick={onMenuOpen}
|
||||
variant="bare"
|
||||
aria-label={t('Menu')}
|
||||
onPress={onMenuOpen}
|
||||
>
|
||||
<SvgDotsHorizontalTriple
|
||||
width={15}
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { theme, styles } from '../../../../style';
|
||||
import { Text } from '../../../common/Text';
|
||||
import { View } from '../../../common/View';
|
||||
import { type SheetFields, type Binding } from '../../../spreadsheet';
|
||||
import { CellValue } from '../../../spreadsheet/CellValue';
|
||||
|
||||
type BudgetTotalProps = {
|
||||
type BudgetTotalProps<
|
||||
CurrentField extends SheetFields<'report-budget'>,
|
||||
TargetField extends SheetFields<'report-budget'>,
|
||||
> = {
|
||||
title: ReactNode;
|
||||
current: ComponentProps<typeof CellValue>['binding'];
|
||||
target: ComponentProps<typeof CellValue>['binding'];
|
||||
current: Binding<'report-budget', CurrentField>;
|
||||
target: Binding<'report-budget', TargetField>;
|
||||
ProgressComponent: ComponentType<{ current; target }>;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
export function BudgetTotal({
|
||||
export function BudgetTotal<
|
||||
CurrentField extends SheetFields<'report-budget'>,
|
||||
TargetField extends SheetFields<'report-budget'>,
|
||||
>({
|
||||
title,
|
||||
current,
|
||||
target,
|
||||
ProgressComponent,
|
||||
style,
|
||||
}: BudgetTotalProps) {
|
||||
}: BudgetTotalProps<CurrentField, TargetField>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
@@ -45,7 +53,8 @@ export function BudgetTotal({
|
||||
<Text>
|
||||
<CellValue binding={current} type="financial" />
|
||||
<Text style={{ color: theme.pageTextSubdued, fontStyle: 'italic' }}>
|
||||
{' of '}
|
||||
{' '}
|
||||
{t('of')}{' '}
|
||||
<CellValue
|
||||
binding={target}
|
||||
type="financial"
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import React, { type ComponentProps } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { theme } from '../../../../style';
|
||||
import { type CellValue } from '../../../spreadsheet/CellValue';
|
||||
import { useSheetValue } from '../../../spreadsheet/useSheetValue';
|
||||
import { type Binding } from '../../../spreadsheet';
|
||||
import { useReportSheetValue } from '../ReportComponents';
|
||||
|
||||
import { fraction } from './fraction';
|
||||
import { PieProgress } from './PieProgress';
|
||||
|
||||
type ExpenseProgressProps = {
|
||||
current: ComponentProps<typeof CellValue>['binding'];
|
||||
target: ComponentProps<typeof CellValue>['binding'];
|
||||
current: Binding<'report-budget', 'total-spent'>;
|
||||
target: Binding<'report-budget', 'total-budgeted'>;
|
||||
};
|
||||
export function ExpenseProgress({ current, target }: ExpenseProgressProps) {
|
||||
let totalSpent = useSheetValue(current) || 0;
|
||||
const totalBudgeted = useSheetValue(target) || 0;
|
||||
let totalSpent = useReportSheetValue(current) || 0;
|
||||
const totalBudgeted = useReportSheetValue(target) || 0;
|
||||
|
||||
// Reverse total spent, and also set a bottom boundary of 0 (in case
|
||||
// income goes into an expense category and it's "positive", don't
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { reportBudget } from 'loot-core/src/client/queries';
|
||||
|
||||
@@ -11,9 +12,10 @@ type ExpenseTotalProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
export function ExpenseTotal({ style }: ExpenseTotalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<BudgetTotal
|
||||
title="Expenses"
|
||||
title={t('Expenses')}
|
||||
current={reportBudget.totalSpent}
|
||||
target={reportBudget.totalBudgetedExpense}
|
||||
ProgressComponent={ExpenseProgress}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { reportBudget } from 'loot-core/src/client/queries';
|
||||
|
||||
@@ -11,9 +12,10 @@ type IncomeTotalProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
export function IncomeTotal({ style }: IncomeTotalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<BudgetTotal
|
||||
title="Income"
|
||||
title={t('Income')}
|
||||
current={reportBudget.totalIncome}
|
||||
target={reportBudget.totalBudgetedIncome}
|
||||
ProgressComponent={IncomeProgress}
|
||||
|
||||