Compare commits
77 Commits
v26.3.0
...
react-quer
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fae08e07b | ||
|
|
72be07e29b | ||
|
|
1af1591da3 | ||
|
|
51b75df429 | ||
|
|
a4eb17eff2 | ||
|
|
8a3db77cff | ||
|
|
f5a62627f0 | ||
|
|
6c150cf28a | ||
|
|
e069312ac3 | ||
|
|
f266b761c2 | ||
|
|
328b36f124 | ||
|
|
8934df0cb5 | ||
|
|
ab269fa4ea | ||
|
|
9c61cfc145 | ||
|
|
d86c9cf735 | ||
|
|
f95cfbf82c | ||
|
|
767f77fea3 | ||
|
|
d6dcc30e44 | ||
|
|
541df52441 | ||
|
|
c21f85a399 | ||
|
|
047fa3c6c5 | ||
|
|
85e3166495 | ||
|
|
5d4bbc9ebb | ||
|
|
031aac9799 | ||
|
|
c53c5c2f36 | ||
|
|
e968213977 | ||
|
|
6b996c11d8 | ||
|
|
282a99db2f | ||
|
|
3a22f1a153 | ||
|
|
d30162672c | ||
|
|
db03d77e81 | ||
|
|
8a8fb2da51 | ||
|
|
a65ab2b4ce | ||
|
|
f29c031735 | ||
|
|
c06f96f015 | ||
|
|
0b21b572fe | ||
|
|
bf1d220ced | ||
|
|
94dd8f73c0 | ||
|
|
85e08b2e9e | ||
|
|
60e2665fcc | ||
|
|
102be1c54d | ||
|
|
448da13cf5 | ||
|
|
41679235be | ||
|
|
73fa068fe9 | ||
|
|
1fe588c143 | ||
|
|
edce092ae8 | ||
|
|
77411394f6 | ||
|
|
235d94478f | ||
|
|
7e0edd43ec | ||
|
|
fdf5c8d0a9 | ||
|
|
a8ec84ceac | ||
|
|
b727124603 | ||
|
|
8bb7f207f2 | ||
|
|
6e0c15eb12 | ||
|
|
4e2cec2c7a | ||
|
|
078603cadf | ||
|
|
b3a86b5392 | ||
|
|
295a565e55 | ||
|
|
387c8fce16 | ||
|
|
c7ebfd8ad4 | ||
|
|
e1f834371b | ||
|
|
4caee99955 | ||
|
|
286d05d187 | ||
|
|
cf05a7ea01 | ||
|
|
b373b612a4 | ||
|
|
3797cff716 | ||
|
|
9e2793d413 | ||
|
|
3201819df9 | ||
|
|
eca50f28b0 | ||
|
|
c82ee91b12 | ||
|
|
cb8ff337dc | ||
|
|
c37a5a02aa | ||
|
|
8c190dc480 | ||
|
|
b288ce5708 | ||
|
|
8630a4fda6 | ||
|
|
2cc9daf50a | ||
|
|
fbc1025c2b |
74
.cursor/rules/pr-and-commit.mdc
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
description: Rules for AI-generated commits and pull requests
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# PR and Commit Rules for AI Agents
|
||||
|
||||
Canonical source: `.github/agents/pr-and-commit-rules.md`
|
||||
|
||||
## Commit Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Git Safety Rules
|
||||
|
||||
- **Never** update git config
|
||||
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
|
||||
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
|
||||
- **Never** force push to `main`/`master`
|
||||
- **Never** commit unless explicitly asked by the user
|
||||
|
||||
## Pre-Commit Quality Checklist
|
||||
|
||||
Before committing, ensure all of the following:
|
||||
|
||||
- [ ] Commit message is prefixed with `[AI]`
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Labels
|
||||
|
||||
Add the **"AI generated"** label to all AI-created pull requests.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese** (简体中文).
|
||||
|
||||
## Quick-Reference Workflow
|
||||
|
||||
1. Make your changes
|
||||
2. Run `yarn typecheck` — fix any errors
|
||||
3. Run `yarn lint:fix` — fix any remaining lint errors
|
||||
4. Run relevant tests (`yarn test` for all, or workspace-specific)
|
||||
5. Stage files and commit with `[AI]` prefix — do not skip hooks
|
||||
6. When creating a PR:
|
||||
- Use `[AI]` prefix in the title
|
||||
- Add the `"AI generated"` label
|
||||
- Leave the PR template blank (do not fill it in)
|
||||
@@ -37,12 +37,14 @@ async function getPRDetails() {
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
console.log('- Base Branch:', pr.base.ref);
|
||||
console.log('- Head Branch:', pr.head.ref);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
baseBranch: pr.base.ref,
|
||||
headBranch: pr.head.ref,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
|
||||
70
.github/agents/pr-and-commit-rules.md
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# PR and Commit Rules for AI Agents
|
||||
|
||||
This is the single source of truth for all commit and pull request rules that AI agents must follow when working with Actual Budget.
|
||||
|
||||
## Commit Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Git Safety Rules
|
||||
|
||||
- **Never** update git config
|
||||
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
|
||||
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
|
||||
- **Never** force push to `main`/`master`
|
||||
- **Never** commit unless explicitly asked by the user
|
||||
|
||||
## Pre-Commit Quality Checklist
|
||||
|
||||
Before committing, ensure all of the following:
|
||||
|
||||
- [ ] Commit message is prefixed with `[AI]`
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Labels
|
||||
|
||||
Add the **"AI generated"** label to all AI-created pull requests. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. Humans are expected to fill in the Description, Related issue(s), Testing, and Checklist sections.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
|
||||
|
||||
## Quick-Reference Workflow
|
||||
|
||||
Follow these steps when committing and creating PRs:
|
||||
|
||||
1. Make your changes
|
||||
2. Run `yarn typecheck` — fix any errors
|
||||
3. Run `yarn lint:fix` — fix any remaining lint errors
|
||||
4. Run relevant tests (`yarn test` for all, or workspace-specific)
|
||||
5. Stage files and commit with `[AI]` prefix — do not skip hooks
|
||||
6. When creating a PR:
|
||||
- Use `[AI]` prefix in the title
|
||||
- Add the `"AI generated"` label
|
||||
- Leave the PR template blank (do not fill it in)
|
||||
19
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -41,21 +41,12 @@ jobs:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if PR targets master branch
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-base-branch
|
||||
run: |
|
||||
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
if [ "$BASE_BRANCH" = "master" ]; then
|
||||
echo "targets_master=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "targets_master=false" >> $GITHUB_OUTPUT
|
||||
echo "PR does not target master branch, skipping release notes generation"
|
||||
fi
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
|
||||
if: >-
|
||||
steps.check-first-comment.outputs.result == 'true' &&
|
||||
steps.pr-details.outputs.result != 'null' &&
|
||||
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
|
||||
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
|
||||
7
.github/workflows/check.yml
vendored
@@ -60,8 +60,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: 22
|
||||
download-translations: 'false'
|
||||
- name: Check migrations
|
||||
run: node ./.github/actions/check-migrations.js
|
||||
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
|
||||
|
||||
4
.github/workflows/docker-edge.yml
vendored
@@ -87,8 +87,8 @@ jobs:
|
||||
- name: Test that the docker image boots
|
||||
run: |
|
||||
docker run --detach --network=host actualbudget/actual-server-testing
|
||||
sleep 5
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
|
||||
sleep 10
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
|
||||
|
||||
# This will use the cache from the earlier build step and not rebuild the image
|
||||
# https://docs.docker.com/build/ci/github-actions/test-before-push/
|
||||
|
||||
50
.github/workflows/electron-master.yml
vendored
@@ -156,53 +156,3 @@ jobs:
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
|
||||
publish-flathub:
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: actual-electron-ubuntu-22.04
|
||||
|
||||
- name: Calculate AppImage SHA256
|
||||
id: appimage_sha256
|
||||
run: |
|
||||
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
|
||||
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
|
||||
- name: Update manifest with new SHA256
|
||||
run: |
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
branch: 'release/${{ needs.build.outputs.version }}'
|
||||
draft: true
|
||||
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo
|
||||
|
||||
126
.github/workflows/publish-flathub.yml
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
name: Publish Flathub
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v25.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-flathub
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-flathub:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: resolve_version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||
TAG="$RELEASE_TAG"
|
||||
else
|
||||
TAG="$INPUT_TAG"
|
||||
fi
|
||||
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "::error::No tag provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag=$TAG version=$VERSION"
|
||||
|
||||
- name: Verify release assets exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ steps.resolve_version.outputs.tag }}"
|
||||
|
||||
echo "Checking release assets for tag $TAG..."
|
||||
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
|
||||
|
||||
echo "Found assets:"
|
||||
echo "$ASSETS"
|
||||
|
||||
if ! echo "$ASSETS" | grep -qx "Actual-linux-x86_64.AppImage"; then
|
||||
echo "::error::Missing asset: Actual-linux-x86_64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$ASSETS" | grep -qx "Actual-linux-arm64.AppImage"; then
|
||||
echo "::error::Missing asset: Actual-linux-arm64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All required AppImage assets found."
|
||||
|
||||
- name: Calculate AppImage SHA256 (streamed)
|
||||
run: |
|
||||
VERSION="${{ steps.resolve_version.outputs.version }}"
|
||||
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
|
||||
|
||||
echo "Streaming x86_64 AppImage to compute SHA256..."
|
||||
APPIMAGE_X64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-x86_64.AppImage" | sha256sum | awk '{ print $1 }')
|
||||
echo "x86_64 SHA256: $APPIMAGE_X64_SHA256"
|
||||
|
||||
echo "Streaming arm64 AppImage to compute SHA256..."
|
||||
APPIMAGE_ARM64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-arm64.AppImage" | sha256sum | awk '{ print $1 }')
|
||||
echo "arm64 SHA256: $APPIMAGE_ARM64_SHA256"
|
||||
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
|
||||
- name: Update manifest with new version
|
||||
run: |
|
||||
VERSION="${{ steps.resolve_version.outputs.version }}"
|
||||
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
echo "Updated manifest:"
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
branch: 'release/${{ steps.resolve_version.outputs.version }}'
|
||||
title: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ steps.resolve_version.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.resolve_version.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw'
|
||||
@@ -20,11 +20,13 @@ jobs:
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
@@ -33,6 +35,10 @@ jobs:
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
- name: Pack the core package
|
||||
run: |
|
||||
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
|
||||
|
||||
- name: Build Server & Web
|
||||
run: yarn build:server
|
||||
|
||||
@@ -53,6 +59,7 @@ jobs:
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/loot-core/@actual-app/core.tgz
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
@@ -76,6 +83,12 @@ jobs:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
run: |
|
||||
npm publish loot-core/@actual-app/core.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
|
||||
|
||||
11
.github/workflows/publish-npm-packages.yml
vendored
@@ -16,6 +16,10 @@ jobs:
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Pack the core package
|
||||
run: |
|
||||
yarn workspace @actual-app/core pack --filename @actual-app/core.tgz
|
||||
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
@@ -36,6 +40,7 @@ jobs:
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/loot-core/@actual-app/core.tgz
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
@@ -59,6 +64,12 @@ jobs:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
run: |
|
||||
npm publish loot-core/@actual-app/core.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public
|
||||
|
||||
2
.gitignore
vendored
@@ -33,7 +33,9 @@ packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
packages/component-library/dist
|
||||
packages/loot-core/lib-dist
|
||||
**/.tsbuildinfo
|
||||
packages/sync-server/coverage
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
"parent",
|
||||
["parent", "subpath"],
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": ["loot-core/**"]
|
||||
"elementNamePattern": ["loot-core/**", "@actual-app/core/**"]
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
|
||||
@@ -101,8 +101,18 @@
|
||||
"typescript/no-var-requires": "error",
|
||||
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
// we want to allow unions such as "string | 'network' | 'file-key-mismatch'"
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/no-floating-promises": "warn", // TODO: covert to error
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/require-array-sort-compare": "error",
|
||||
"typescript/unbound-method": "error",
|
||||
"typescript/no-for-in-array": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-misused-spread": "warn", // TODO: enable this
|
||||
"typescript/no-base-to-string": "warn", // TODO: enable this
|
||||
"typescript/no-unsafe-unary-minus": "warn", // TODO: enable this
|
||||
"typescript/no-unsafe-type-assertion": "warn", // TODO: enable this
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "error",
|
||||
|
||||
49
AGENTS.md
@@ -44,25 +44,9 @@ yarn start:desktop
|
||||
|
||||
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
|
||||
|
||||
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
|
||||
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
|
||||
|
||||
- **ALL commit messages MUST be prefixed with `[AI]`**
|
||||
- **ALL pull request titles MUST be prefixed with `[AI]`**
|
||||
|
||||
**Examples:**
|
||||
|
||||
- ✅ `[AI] Fix type error in account validation`
|
||||
- ✅ `[AI] Add support for new transaction categories`
|
||||
- ❌ `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- ❌ `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
**This requirement applies to:**
|
||||
|
||||
- Every single commit message created by AI agents
|
||||
- Every single pull request title created by AI agents
|
||||
- No exceptions are permitted
|
||||
|
||||
**This is a hard requirement that agents MUST follow. Failure to include the `[AI]` prefix is a violation of these instructions.**
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
|
||||
|
||||
### Task Orchestration with Lage
|
||||
|
||||
@@ -100,7 +84,7 @@ The core application logic that runs on any platform.
|
||||
|
||||
```bash
|
||||
# Run all loot-core tests
|
||||
yarn workspace loot-core run test
|
||||
yarn workspace @actual-app/core run test
|
||||
|
||||
# Or run tests across all packages using lage
|
||||
yarn test
|
||||
@@ -235,7 +219,7 @@ yarn test
|
||||
yarn test:debug
|
||||
|
||||
# Run tests for a specific package
|
||||
yarn workspace loot-core run test
|
||||
yarn workspace @actual-app/core run test
|
||||
```
|
||||
|
||||
**E2E Tests (Playwright)**
|
||||
@@ -314,6 +298,7 @@ Always run `yarn typecheck` before committing.
|
||||
|
||||
**React Patterns:**
|
||||
|
||||
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
|
||||
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
|
||||
- Don't use `React.*` patterns - use named imports instead
|
||||
- Use `<Link>` instead of `<a>` tags
|
||||
@@ -360,13 +345,7 @@ Always maintain newlines between import groups.
|
||||
|
||||
**Git Commands:**
|
||||
|
||||
- **MANDATORY: ALL commit messages MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
|
||||
- **MANDATORY: ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
|
||||
- Never update git config
|
||||
- Never run destructive git operations (force push, hard reset) unless explicitly requested
|
||||
- Never skip hooks (--no-verify, --no-gpg-sign)
|
||||
- Never force push to main/master
|
||||
- Never commit unless explicitly asked
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
|
||||
|
||||
## File Structure Patterns
|
||||
|
||||
@@ -565,7 +544,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
Before committing changes, ensure:
|
||||
|
||||
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
|
||||
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
@@ -578,17 +557,7 @@ Before committing changes, ensure:
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
When creating pull requests:
|
||||
|
||||
- **MANDATORY PREFIX REQUIREMENT**: **ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement that MUST be followed without exception
|
||||
- ✅ Correct: `[AI] Fix type error in account validation`
|
||||
- ❌ Incorrect: `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. We expect **humans** to fill in the Description, Related issue(s), Testing, and Checklist sections.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
@@ -656,7 +625,7 @@ Standard commands documented in `package.json` scripts and the Quick Start secti
|
||||
|
||||
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
|
||||
- `yarn test` (lage across all workspaces)
|
||||
- `yarn typecheck` (tsc + lage typecheck)
|
||||
- `yarn typecheck` (tsgo + lage typecheck)
|
||||
|
||||
### Testing and previewing the app
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ packages/desktop-client/bin/remove-untranslated-languages
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
echo "packages/desktop-client/build"
|
||||
|
||||
@@ -51,14 +51,17 @@ fi
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace loot-core build:node
|
||||
yarn workspace @actual-app/core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
# required for running the sync-server server
|
||||
yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
# Emit @actual-app/core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
yarn workspace @actual-app/core exec tsgo -p tsconfig.json
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
(
|
||||
|
||||
@@ -3,6 +3,7 @@ module.exports = {
|
||||
pipeline: {
|
||||
typecheck: {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^typecheck'],
|
||||
},
|
||||
test: {
|
||||
type: 'npmScript',
|
||||
|
||||
17
package.json
@@ -25,16 +25,16 @@
|
||||
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
|
||||
"start:docs": "yarn workspace docs start",
|
||||
"desktop-dependencies": "npm-run-all --parallel rebuild-electron build:browser-backend build:plugins-service",
|
||||
"start:desktop-node": "yarn workspace loot-core watch:node",
|
||||
"start:desktop-node": "yarn workspace @actual-app/core watch:node",
|
||||
"start:desktop-client": "yarn workspace @actual-app/web watch",
|
||||
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
|
||||
"start:desktop-electron": "yarn workspace desktop-electron watch",
|
||||
"start:browser": "yarn workspace plugins-service build-dev && npm-run-all --parallel 'start:browser-*'",
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
@@ -53,11 +53,11 @@
|
||||
"vrt": "yarn workspace @actual-app/web run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
|
||||
"rebuild-node": "yarn workspace @actual-app/core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
@@ -65,6 +65,7 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -95,7 +96,7 @@
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"oxlint --fix --type-aware"
|
||||
"oxlint --fix --type-aware --quiet"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -3,26 +3,18 @@ import type {
|
||||
RequestInit as FetchInit,
|
||||
} from 'node-fetch';
|
||||
|
||||
// loot-core types
|
||||
import type { InitConfig } from 'loot-core/server/main';
|
||||
import { init as initLootCore } from '@actual-app/core/server/main';
|
||||
import type { InitConfig, lib } from '@actual-app/core/server/main';
|
||||
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
import { validateNodeVersion } from './validateNodeVersion';
|
||||
|
||||
let actualApp: null | typeof bundle.lib;
|
||||
export const internal = bundle.lib;
|
||||
|
||||
export * from './methods';
|
||||
export * as utils from './utils';
|
||||
|
||||
export async function init(config: InitConfig = {}) {
|
||||
if (actualApp) {
|
||||
return;
|
||||
}
|
||||
/** @deprecated Please use return value of `init` instead */
|
||||
export let internal: typeof lib | null = null;
|
||||
|
||||
export async function init(config: InitConfig = {}) {
|
||||
validateNodeVersion();
|
||||
|
||||
if (!globalThis.fetch) {
|
||||
@@ -33,21 +25,19 @@ export async function init(config: InitConfig = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
await bundle.init(config);
|
||||
actualApp = bundle.lib;
|
||||
|
||||
injected.override(bundle.lib.send);
|
||||
return bundle.lib;
|
||||
internal = await initLootCore(config);
|
||||
return internal;
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
if (actualApp) {
|
||||
if (internal) {
|
||||
try {
|
||||
await actualApp.send('sync');
|
||||
await internal.send('sync');
|
||||
} catch {
|
||||
// most likely that no budget is loaded, so the sync failed
|
||||
}
|
||||
await actualApp.send('close-budget');
|
||||
actualApp = null;
|
||||
|
||||
await internal.send('close-budget');
|
||||
internal = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// TODO: comment on why it works this way
|
||||
|
||||
export let send;
|
||||
|
||||
export function override(sendImplementation) {
|
||||
send = sendImplementation;
|
||||
}
|
||||
@@ -1,10 +1,29 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import type { RuleEntity } from '@actual-app/core/types/models';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
// In tests we run from source; loot-core's API fs uses __dirname (for the built dist/).
|
||||
// Mock the fs so path constants point at loot-core package root where migrations live.
|
||||
vi.mock(
|
||||
'../loot-core/src/platform/server/fs/index.api',
|
||||
async importOriginal => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
const pathMod = await import('path');
|
||||
const lootCoreRoot = pathMod.join(__dirname, '..', 'loot-core');
|
||||
return {
|
||||
...actual,
|
||||
migrationsPath: pathMod.join(lootCoreRoot, 'migrations'),
|
||||
bundledDatabasePath: pathMod.join(lootCoreRoot, 'default-db.sqlite'),
|
||||
demoBudgetPath: pathMod.join(lootCoreRoot, 'demo-budget'),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const budgetName = 'test-budget';
|
||||
|
||||
global.IS_TESTING = true;
|
||||
|
||||
@@ -6,16 +6,16 @@ import type {
|
||||
APIPayeeEntity,
|
||||
APIScheduleEntity,
|
||||
APITagEntity,
|
||||
} from 'loot-core/server/api-models';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
} from '@actual-app/core/server/api-models';
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
import type { Query } from '@actual-app/core/shared/query';
|
||||
import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers';
|
||||
import type { Handlers } from '@actual-app/core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import * as injected from './injected';
|
||||
} from '@actual-app/core/types/models';
|
||||
|
||||
export { q } from './app/query';
|
||||
|
||||
@@ -23,7 +23,7 @@ function send<K extends keyof Handlers, T extends Handlers[K]>(
|
||||
name: K,
|
||||
args?: Parameters<T>[0],
|
||||
): Promise<Awaited<ReturnType<T>>> {
|
||||
return injected.send(name, args);
|
||||
return lib.send(name, args);
|
||||
}
|
||||
|
||||
export async function runImport(
|
||||
@@ -126,11 +126,6 @@ export function addTransactions(
|
||||
});
|
||||
}
|
||||
|
||||
export type ImportTransactionsOpts = {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export function importTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
transactions: ImportTransactionEntity[],
|
||||
|
||||
@@ -10,28 +10,26 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"scripts": {
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
"build:node": "tsc && tsc-alias",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
|
||||
"clean": "rm -rf dist @types",
|
||||
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
|
||||
"build": "vite build",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b && tsc-strict"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vitest": "^4.0.18"
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vite-plugin-peggy-loader": "^2.0.1",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node10",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
|
||||
}
|
||||
|
||||
2
packages/api/typings.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module 'hyperformula/i18n/languages/enUS';
|
||||
declare module '*.pegjs';
|
||||
@@ -1,6 +1,4 @@
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import { lib } from '@actual-app/core/server/main';
|
||||
|
||||
export const amountToInteger = bundle.lib.amountToInteger;
|
||||
export const integerToAmount = bundle.lib.integerToAmount;
|
||||
export const amountToInteger = lib.amountToInteger;
|
||||
export const integerToAmount = lib.integerToAmount;
|
||||
|
||||
93
packages/api/vite.config.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
|
||||
const lootCoreRoot = path.resolve(__dirname, '../loot-core');
|
||||
const distDir = path.resolve(__dirname, 'dist');
|
||||
const typesDir = path.resolve(__dirname, '@types');
|
||||
|
||||
function cleanOutputDirs() {
|
||||
return {
|
||||
name: 'clean-output-dirs',
|
||||
buildStart() {
|
||||
if (fs.existsSync(distDir)) fs.rmSync(distDir, { recursive: true });
|
||||
if (fs.existsSync(typesDir)) fs.rmSync(typesDir, { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function copyMigrationsAndDefaultDb() {
|
||||
return {
|
||||
name: 'copy-migrations-and-default-db',
|
||||
closeBundle() {
|
||||
const migrationsSrc = path.join(lootCoreRoot, 'migrations');
|
||||
const defaultDbPath = path.join(lootCoreRoot, 'default-db.sqlite');
|
||||
|
||||
if (!fs.existsSync(migrationsSrc)) {
|
||||
throw new Error(`migrations directory not found at ${migrationsSrc}`);
|
||||
}
|
||||
const migrationsStat = fs.statSync(migrationsSrc);
|
||||
if (!migrationsStat.isDirectory()) {
|
||||
throw new Error(`migrations path is not a directory: ${migrationsSrc}`);
|
||||
}
|
||||
|
||||
const migrationsDest = path.join(distDir, 'migrations');
|
||||
fs.mkdirSync(migrationsDest, { recursive: true });
|
||||
for (const name of fs.readdirSync(migrationsSrc)) {
|
||||
if (name.endsWith('.sql') || name.endsWith('.js')) {
|
||||
fs.copyFileSync(
|
||||
path.join(migrationsSrc, name),
|
||||
path.join(migrationsDest, name),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(defaultDbPath)) {
|
||||
throw new Error(`default-db.sqlite not found at ${defaultDbPath}`);
|
||||
}
|
||||
fs.copyFileSync(defaultDbPath, path.join(distDir, 'default-db.sqlite'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
ssr: { noExternal: true, external: ['better-sqlite3'] },
|
||||
build: {
|
||||
ssr: true,
|
||||
target: 'node20',
|
||||
outDir: distDir,
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'index.ts'),
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'index.js',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
cleanOutputDirs(),
|
||||
peggyLoader(),
|
||||
dts({
|
||||
tsconfigPath: path.resolve(__dirname, 'tsconfig.json'),
|
||||
outDir: path.resolve(__dirname, '@types'),
|
||||
rollupTypes: true,
|
||||
}),
|
||||
copyMigrationsAndDefaultDb(),
|
||||
visualizer({ template: 'raw-data', filename: 'app/stats.json' }),
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.api.ts', '.js', '.ts', '.tsx', '.json'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
maxWorkers: 2,
|
||||
},
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
test: {
|
||||
globals: true,
|
||||
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
},
|
||||
maxWorkers: 2,
|
||||
},
|
||||
};
|
||||
1
packages/ci-actions/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist/*
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// overview:
|
||||
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
|
||||
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const migrationsDir = path.join(
|
||||
__dirname,
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
|
||||
'migrations',
|
||||
);
|
||||
|
||||
function readMigrations(ref) {
|
||||
function readMigrations(ref: string) {
|
||||
const { stdout } = spawnSync('git', [
|
||||
'ls-tree',
|
||||
'--name-only',
|
||||
@@ -3,9 +3,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --run"
|
||||
"tsx": "node --import=extensionless/register --experimental-strip-types",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.18"
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"extensionless": "^2.0.6",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
"ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
16
packages/ci-actions/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*", "bin/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
@@ -32,11 +32,9 @@ const config: StorybookConfig = {
|
||||
const { mergeConfig } = await import('vite');
|
||||
|
||||
return mergeConfig(config, {
|
||||
// Telling Vite how to resolve path aliases
|
||||
plugins: [viteTsconfigPaths({ root: '../..' })],
|
||||
esbuild: {
|
||||
// Needed to handle JSX in .ts/.tsx files
|
||||
jsx: 'automatic',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode } from 'react';
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
|
||||
// TODO: this needs refactoring
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
|
||||
"start:storybook": "storybook dev -p 6006",
|
||||
"build:storybook": "storybook build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
@@ -54,11 +54,14 @@
|
||||
"@storybook/react-vite": "^10.2.7",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint-plugin-storybook": "^10.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.7",
|
||||
"vitest": "^4.0.18"
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.2",
|
||||
|
||||
@@ -16,8 +16,8 @@ import { View } from './View';
|
||||
|
||||
const MenuLine: unique symbol = Symbol('menu-line');
|
||||
const MenuLabel: unique symbol = Symbol('menu-label');
|
||||
Menu.line = MenuLine;
|
||||
Menu.label = MenuLabel;
|
||||
Menu.line = MenuLine as typeof MenuLine;
|
||||
Menu.label = MenuLabel as typeof MenuLabel;
|
||||
|
||||
type KeybindingProps = {
|
||||
keyName: ReactNode;
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import path from 'path';
|
||||
|
||||
import react from '@vitejs/plugin-react';
|
||||
import peggyLoader from 'vite-plugin-peggy-loader';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
@@ -23,13 +24,7 @@ export default defineConfig({
|
||||
maxWorkers: 2,
|
||||
},
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: /^@actual-app\/crdt(\/.*)?$/,
|
||||
replacement: path.resolve('../../../crdt/src$1'),
|
||||
},
|
||||
],
|
||||
extensions: resolveExtensions,
|
||||
},
|
||||
plugins: [peggyLoader()],
|
||||
plugins: [react(), peggyLoader()],
|
||||
});
|
||||
|
||||
@@ -8,12 +8,23 @@
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build:node": "tsc",
|
||||
"build:node": "tsgo",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
@@ -22,9 +33,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "3.15.12",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
|
||||
while (true) {
|
||||
const keyset = new Set([...getKeys(node1), ...getKeys(node2)]);
|
||||
const keys = [...keyset.values()];
|
||||
keys.sort();
|
||||
keys.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
let diffkey: null | '0' | '1' | '2' = null;
|
||||
|
||||
@@ -145,7 +145,7 @@ export function prune(trie: TrieNode, n = 2): TrieNode {
|
||||
}
|
||||
|
||||
const keys = getKeys(trie);
|
||||
keys.sort();
|
||||
keys.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const next: TrieNode = { hash: trie.hash };
|
||||
|
||||
|
||||
@@ -121,12 +121,12 @@ describe('Timestamp', function () {
|
||||
it('should fail with counter overflow', function () {
|
||||
now = 40;
|
||||
for (let i = 0; i < 65536; i++) Timestamp.send();
|
||||
expect(Timestamp.send).toThrow(Timestamp.OverflowError);
|
||||
expect(() => Timestamp.send()).toThrow(Timestamp.OverflowError);
|
||||
});
|
||||
|
||||
it('should fail with clock drift', function () {
|
||||
now = -(5 * 60 * 1000 + 1);
|
||||
expect(Timestamp.send).toThrow(Timestamp.ClockDriftError);
|
||||
expect(() => Timestamp.send()).toThrow(Timestamp.ClockDriftError);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
@@ -8,8 +9,10 @@
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
|
||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -61,7 +61,7 @@ export class ConfigurationPage {
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unrecognized import type: ${type}`);
|
||||
throw new Error(`Unrecognized import type: ${String(type)}`);
|
||||
}
|
||||
|
||||
const fileChooser = await fileChooserPromise;
|
||||
|
||||
@@ -39,7 +39,7 @@ export class CustomReportPage {
|
||||
.click();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized mode: ${mode}`);
|
||||
throw new Error(`Unrecognized mode: ${String(mode)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -16,10 +16,12 @@
|
||||
"e2e": "npx playwright test --browser=chromium",
|
||||
"vrt": "cross-env VRT=true npx playwright test --browser=chromium",
|
||||
"playwright": "playwright",
|
||||
"typecheck": "tsc --noEmit && tsc-strict"
|
||||
"typecheck": "tsgo -b && tsc-strict"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actual-app/components": "workspace:*",
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@rolldown/plugin-babel": "~0.1.7",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "^0.5.18",
|
||||
@@ -45,10 +48,11 @@
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.1.4",
|
||||
"@vitejs/plugin-react": "^5.1.3",
|
||||
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -61,7 +65,6 @@
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"lodash": "^4.17.23",
|
||||
"loot-core": "workspace:*",
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"pikaday": "1.8.2",
|
||||
@@ -91,14 +94,12 @@
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"sass": "^1.97.3",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.18",
|
||||
"vitest": "^4.1.0",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,7 +779,7 @@ export function useBudgetActions() {
|
||||
});
|
||||
return null;
|
||||
default:
|
||||
throw new Error(`Unknown budget action type: ${type}`);
|
||||
throw new Error(`Unknown budget action type: ${String(type)}`);
|
||||
}
|
||||
},
|
||||
onSuccess: notification => {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { handleGlobalEvents } from '@desktop-client/global-events';
|
||||
import { useIsTestEnv } from '@desktop-client/hooks/useIsTestEnv';
|
||||
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
|
||||
import { useOnVisible } from '@desktop-client/hooks/useOnVisible';
|
||||
import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
|
||||
import { setI18NextLanguage } from '@desktop-client/i18n';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
@@ -179,6 +180,11 @@ export function App() {
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useOnVisible(async () => {
|
||||
console.debug('triggering sync because of visibility change');
|
||||
await dispatch(sync());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function checkScrollbars() {
|
||||
if (hiddenScrollbars !== hasHiddenScrollbars()) {
|
||||
@@ -186,25 +192,9 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
let isSyncing = false;
|
||||
|
||||
async function onVisibilityChange() {
|
||||
if (!isSyncing) {
|
||||
console.debug('triggering sync because of visibility change');
|
||||
isSyncing = true;
|
||||
await dispatch(sync());
|
||||
isSyncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', checkScrollbars);
|
||||
window.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', checkScrollbars);
|
||||
window.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [dispatch, hiddenScrollbars]);
|
||||
return () => window.removeEventListener('focus', checkScrollbars);
|
||||
}, [hiddenScrollbars]);
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { accountQueries } from '@desktop-client/accounts';
|
||||
import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice';
|
||||
import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute';
|
||||
import { Permissions } from '@desktop-client/auth/types';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
|
||||
@@ -91,10 +92,7 @@ export function FinancesApp() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results.
|
||||
const { data: accounts, isFetching: isAccountsFetching } = useQuery(
|
||||
accountQueries.list(),
|
||||
);
|
||||
const { data: accounts, isFetching: isAccountsFetching } = useAccounts();
|
||||
|
||||
const versionInfo = useSelector(state => state.app.versionInfo);
|
||||
const [notifyWhenUpdateIsAvailable] = useGlobalPref(
|
||||
|
||||
@@ -95,7 +95,7 @@ export const HelpMenu = () => {
|
||||
dispatch(pushModal({ modal: { name: 'goal-templates' } }));
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu option: ${item}`);
|
||||
throw new Error(`Unrecognized menu option: ${String(item)}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -30,7 +30,9 @@ import { RulesList } from './rules/RulesList';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { usePayeeRules } from '@desktop-client/hooks/usePayeeRules';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import {
|
||||
SelectedProvider,
|
||||
@@ -38,6 +40,10 @@ import {
|
||||
} from '@desktop-client/hooks/useSelected';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useBatchDeleteRulesMutation,
|
||||
useDeleteRuleMutation,
|
||||
} from '@desktop-client/rules';
|
||||
|
||||
export type FilterData = {
|
||||
payees?: Array<{ id: string; name: string }>;
|
||||
@@ -115,17 +121,36 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
|
||||
type ManageRulesProps = {
|
||||
isModal: boolean;
|
||||
payeeId: string | null;
|
||||
setLoading?: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function ManageRules({
|
||||
isModal,
|
||||
payeeId,
|
||||
setLoading = () => {},
|
||||
}: ManageRulesProps) {
|
||||
export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const {
|
||||
data: allRules = [],
|
||||
refetch: refetchAllRules,
|
||||
isLoading: isAllRulesLoading,
|
||||
isRefetching: isAllRulesRefetching,
|
||||
} = useRules({
|
||||
enabled: !payeeId,
|
||||
});
|
||||
const {
|
||||
data: payeeRules = [],
|
||||
refetch: refetchPayeeRules,
|
||||
isLoading: isPayeeRulesLoading,
|
||||
isRefetching: isPayeeRulesRefetching,
|
||||
} = usePayeeRules({
|
||||
payeeId,
|
||||
});
|
||||
|
||||
const rulesToUse = payeeId ? payeeRules : allRules;
|
||||
const refetchRules = payeeId ? refetchPayeeRules : refetchAllRules;
|
||||
const isLoading =
|
||||
isAllRulesLoading ||
|
||||
isAllRulesRefetching ||
|
||||
isPayeeRulesLoading ||
|
||||
isPayeeRulesRefetching;
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
@@ -147,7 +172,7 @@ export function ManageRules({
|
||||
);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const rules = allRules.filter(rule => {
|
||||
const rules = rulesToUse.filter(rule => {
|
||||
const schedule = schedules.find(schedule => schedule.rule === rule.id);
|
||||
return schedule ? schedule.completed === false : true;
|
||||
});
|
||||
@@ -161,7 +186,7 @@ export function ManageRules({
|
||||
),
|
||||
)
|
||||
).slice(0, 100 + page * 50);
|
||||
}, [allRules, filter, filterData, page, schedules]);
|
||||
}, [rulesToUse, filter, filterData, page, schedules]);
|
||||
|
||||
const selectedInst = useSelected('manage-rules', filteredRules, []);
|
||||
const [hoveredRule, setHoveredRule] = useState(null);
|
||||
@@ -171,38 +196,16 @@ export function ManageRules({
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
async function loadRules() {
|
||||
setLoading(true);
|
||||
|
||||
let loadedRules = null;
|
||||
if (payeeId) {
|
||||
loadedRules = await send('payees-get-rules', {
|
||||
id: payeeId,
|
||||
});
|
||||
} else {
|
||||
loadedRules = await send('rules-get');
|
||||
}
|
||||
|
||||
setAllRules(loadedRules);
|
||||
return loadedRules;
|
||||
}
|
||||
|
||||
const init = useEffectEvent(() => {
|
||||
async function loadData() {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (payeeId) {
|
||||
undo.setUndoState('openModal', { name: 'manage-rules', options: {} });
|
||||
}
|
||||
|
||||
void loadData();
|
||||
|
||||
return () => {
|
||||
undo.setUndoState('openModal', null);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return init();
|
||||
}, []);
|
||||
@@ -211,29 +214,33 @@ export function ManageRules({
|
||||
setPage(page => page + 1);
|
||||
}
|
||||
|
||||
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
|
||||
|
||||
const onDeleteSelected = async () => {
|
||||
setLoading(true);
|
||||
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
...selectedInst.items,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
alert(
|
||||
t('Some rules were not deleted because they are linked to schedules.'),
|
||||
);
|
||||
}
|
||||
|
||||
await loadRules();
|
||||
selectedInst.dispatch({ type: 'select-none' });
|
||||
setLoading(false);
|
||||
batchDeleteRules(
|
||||
{
|
||||
ids: [...selectedInst.items],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void refetchRules();
|
||||
selectedInst.dispatch({ type: 'select-none' });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
async function onDeleteRule(id: string) {
|
||||
setLoading(true);
|
||||
await send('rule-delete', id);
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
function onDeleteRule(id: string) {
|
||||
deleteRule(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const onEditRule = rule => {
|
||||
@@ -244,8 +251,7 @@ export function ManageRules({
|
||||
options: {
|
||||
rule,
|
||||
onSave: async () => {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -282,8 +288,7 @@ export function ManageRules({
|
||||
options: {
|
||||
rule,
|
||||
onSave: async () => {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -295,6 +300,24 @@ export function ManageRules({
|
||||
setHoveredRule(id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading width={25} height={25} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isNonDeletableRuleSelected = schedules.some(schedule =>
|
||||
selectedInst.items.has(schedule.rule),
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<View>
|
||||
@@ -361,11 +384,24 @@ export function ManageRules({
|
||||
>
|
||||
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
|
||||
{selectedInst.items.size > 0 && (
|
||||
<Button onPress={onDeleteSelected}>
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
<Tooltip
|
||||
isOpen={isNonDeletableRuleSelected}
|
||||
content={
|
||||
<Trans>
|
||||
Some selected rules cannot be deleted because they are
|
||||
linked to schedules.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onPress={onDeleteSelected}
|
||||
isDisabled={isNonDeletableRuleSelected}
|
||||
>
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
<Trans>Create new rule</Trans>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { CategoryMenuModal } from './modals/CategoryMenuModal';
|
||||
import { CloseAccountModal } from './modals/CloseAccountModal';
|
||||
import { ConfirmCategoryDeleteModal } from './modals/ConfirmCategoryDeleteModal';
|
||||
import { ConfirmDeleteModal } from './modals/ConfirmDeleteModal';
|
||||
import { ConfirmPayeesMergeModal } from './modals/ConfirmPayeesMergeModal';
|
||||
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
|
||||
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
|
||||
import { ConvertToScheduleModal } from './modals/ConvertToScheduleModal';
|
||||
@@ -140,6 +141,9 @@ export function Modals() {
|
||||
case 'confirm-category-delete':
|
||||
return <ConfirmCategoryDeleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-payees-merge':
|
||||
return <ConfirmPayeesMergeModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-unlink-account':
|
||||
return <ConfirmUnlinkAccountModal key={key} {...modal.options} />;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { t } from 'i18next';
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
|
||||
import { useOnVisible } from '@desktop-client/hooks/useOnVisible';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
@@ -110,6 +111,16 @@ export function ServerProvider({ children }: { children: ReactNode }) {
|
||||
void run();
|
||||
}, []);
|
||||
|
||||
useOnVisible(
|
||||
async () => {
|
||||
const version = await getServerVersion();
|
||||
setVersion(version);
|
||||
},
|
||||
{
|
||||
isEnabled: !!serverURL,
|
||||
},
|
||||
);
|
||||
|
||||
const refreshLoginMethods = useCallback(async () => {
|
||||
if (serverURL) {
|
||||
const data: Awaited<ReturnType<Handlers['subscribe-get-login-methods']>> =
|
||||
|
||||
@@ -106,11 +106,11 @@ function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type SyncButtonProps = {
|
||||
type ServerSyncButtonProps = {
|
||||
style?: CSSProperties;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
function ServerSyncButton({ style, isMobile = false }: ServerSyncButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cloudFileId] = useMetadataPref('cloudFileId');
|
||||
const dispatch = useDispatch();
|
||||
@@ -166,7 +166,7 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
: syncState === 'disabled' ||
|
||||
syncState === 'offline' ||
|
||||
syncState === 'local'
|
||||
? theme.tableTextLight
|
||||
? theme.buttonBareDisabledText
|
||||
: 'inherit';
|
||||
|
||||
const activeStyle = isMobile
|
||||
@@ -213,7 +213,7 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={t('Sync')}
|
||||
aria-label={t('Server Sync')}
|
||||
className={css({
|
||||
...(isMobile
|
||||
? {
|
||||
@@ -230,6 +230,8 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
'&[data-pressed]': activeStyle,
|
||||
})}
|
||||
onPress={onSync}
|
||||
isDisabled={syncState === 'offline'}
|
||||
aria-disabled={syncState === 'offline'}
|
||||
>
|
||||
{isMobile ? (
|
||||
syncState === 'error' ? (
|
||||
@@ -243,11 +245,7 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
<AnimatedRefresh animating={syncing} />
|
||||
)}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
|
||||
{syncState === 'disabled'
|
||||
? t('Disabled')
|
||||
: syncState === 'offline'
|
||||
? t('Offline')
|
||||
: t('Sync')}
|
||||
{syncState === 'disabled' ? t('Disabled') : null}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
@@ -346,7 +344,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
<UncategorizedButton />
|
||||
{isDevelopmentEnvironment() && !isTestEnv && <ThemeSelector />}
|
||||
<PrivacyButton />
|
||||
{serverURL ? <SyncButton /> : null}
|
||||
{serverURL ? <ServerSyncButton /> : null}
|
||||
<LoggedInUser />
|
||||
<HelpMenu />
|
||||
</SpaceBetween>
|
||||
|
||||
@@ -83,6 +83,7 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
|
||||
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
|
||||
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
|
||||
@@ -251,6 +252,7 @@ type AccountInternalProps = {
|
||||
onUnlinkAccount: (id: AccountEntity['id']) => void;
|
||||
onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
|
||||
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
|
||||
onRunRules: (transaction: TransactionEntity) => Promise<TransactionEntity>;
|
||||
};
|
||||
|
||||
type AccountInternalState = {
|
||||
@@ -691,9 +693,8 @@ class AccountInternal extends PureComponent<
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const res: TransactionEntity | null = await send('rules-run', {
|
||||
transaction,
|
||||
});
|
||||
const res: TransactionEntity | null =
|
||||
await this.props.onRunRules(transaction);
|
||||
if (res) {
|
||||
changedTransactions.push(...ungroupTransaction(res));
|
||||
|
||||
@@ -1055,10 +1056,9 @@ class AccountInternal extends PureComponent<
|
||||
});
|
||||
|
||||
// run rules on the reconciliation transaction
|
||||
const runRules = this.props.onRunRules;
|
||||
const ruledTransactions = await Promise.all(
|
||||
reconciliationTransactions.map(transaction =>
|
||||
send('rules-run', { transaction }),
|
||||
),
|
||||
reconciliationTransactions.map(transaction => runRules(transaction)),
|
||||
);
|
||||
|
||||
// sync the reconciliation transaction
|
||||
@@ -1661,6 +1661,11 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
|
||||
maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc);
|
||||
|
||||
// Always add sort_order as a final tiebreaker to maintain stable ordering
|
||||
// when transactions have the same values in the sorted column(s)
|
||||
this.currentQuery = this.currentQuery.orderBy({ sort_order: sortAscDesc });
|
||||
|
||||
this.updateQuery(this.currentQuery, isFiltered);
|
||||
};
|
||||
|
||||
@@ -1861,6 +1866,12 @@ class AccountInternal extends PureComponent<
|
||||
accountId === 'onbudget' ||
|
||||
accountId === 'uncategorized'
|
||||
}
|
||||
allowReorder={
|
||||
!!accountId &&
|
||||
accountId !== 'offbudget' &&
|
||||
accountId !== 'onbudget' &&
|
||||
accountId !== 'uncategorized'
|
||||
}
|
||||
isAdding={this.state.isAdding}
|
||||
isNew={this.isNew}
|
||||
isMatched={this.isMatched}
|
||||
@@ -2017,9 +2028,13 @@ export function Account() {
|
||||
const onSyncAndDownload = (id?: AccountEntity['id']) =>
|
||||
syncAndDownload({ id });
|
||||
|
||||
const createPayee = useCreatePayeeMutation();
|
||||
const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
|
||||
const onCreatePayee = (name: PayeeEntity['name']) =>
|
||||
createPayee.mutateAsync({ name });
|
||||
createPayeeAsync({ name });
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
const onRunRules = (transaction: TransactionEntity) =>
|
||||
runRulesAsync({ transaction });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
@@ -2062,6 +2077,7 @@ export function Account() {
|
||||
onUnlinkAccount={onUnlinkAccount}
|
||||
onSyncAndDownload={onSyncAndDownload}
|
||||
onCreatePayee={onCreatePayee}
|
||||
onRunRules={onRunRules}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
|
||||
@@ -68,6 +68,11 @@ function useErrorMessage() {
|
||||
</Trans>
|
||||
);
|
||||
|
||||
case 'ACCOUNT_MISSING':
|
||||
return t(
|
||||
'This account was not found in SimpleFIN. Try unlinking and relinking the account.',
|
||||
);
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ type AlertProps = {
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
style?: CSSProperties;
|
||||
iconStyle?: CSSProperties;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
@@ -23,6 +24,7 @@ const Alert = ({
|
||||
color,
|
||||
backgroundColor,
|
||||
style,
|
||||
iconStyle,
|
||||
children,
|
||||
}: AlertProps) => {
|
||||
return (
|
||||
@@ -48,21 +50,29 @@ const Alert = ({
|
||||
alignSelf: 'stretch',
|
||||
flexShrink: 0,
|
||||
marginRight: 5,
|
||||
...iconStyle,
|
||||
}}
|
||||
>
|
||||
<Icon width={13} style={{ marginTop: 2 }} />
|
||||
</View>
|
||||
<Text style={{ zIndex: 1, lineHeight: 1.5 }}>{children}</Text>
|
||||
<Text style={{ width: '100%', zIndex: 1, lineHeight: 1.5 }}>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type ScopedAlertProps = {
|
||||
style?: CSSProperties;
|
||||
iconStyle?: CSSProperties;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Information = ({ style, children }: ScopedAlertProps) => {
|
||||
export const Information = ({
|
||||
style,
|
||||
iconStyle,
|
||||
children,
|
||||
}: ScopedAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
icon={SvgInformationOutline}
|
||||
@@ -73,32 +83,35 @@ export const Information = ({ style, children }: ScopedAlertProps) => {
|
||||
padding: 5,
|
||||
...style,
|
||||
}}
|
||||
iconStyle={iconStyle}
|
||||
>
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const Warning = ({ style, children }: ScopedAlertProps) => {
|
||||
export const Warning = ({ style, iconStyle, children }: ScopedAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
icon={SvgExclamationOutline}
|
||||
color={theme.warningText}
|
||||
backgroundColor={theme.warningBackground}
|
||||
style={style}
|
||||
iconStyle={iconStyle}
|
||||
>
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const Error = ({ style, children }: ScopedAlertProps) => {
|
||||
export const Error = ({ style, iconStyle, children }: ScopedAlertProps) => {
|
||||
return (
|
||||
<Alert
|
||||
icon={SvgExclamationOutline}
|
||||
color={theme.errorTextDarker}
|
||||
backgroundColor={theme.errorBackground}
|
||||
style={style}
|
||||
iconStyle={iconStyle}
|
||||
>
|
||||
{children}
|
||||
</Alert>
|
||||
|
||||
@@ -462,184 +462,190 @@ function SingleAutocomplete<T extends AutocompleteItem>({
|
||||
isOpen,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
}) => (
|
||||
// Super annoying but it works best to return a div so we
|
||||
// can't use a View here, but we can fake it be using the
|
||||
// className
|
||||
<div
|
||||
className={cx('view', css({ display: 'flex' }))}
|
||||
{...containerProps}
|
||||
>
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
(() => {
|
||||
const { className, style, ...restInputProps } =
|
||||
inputProps || {};
|
||||
const downshiftProps = getInputProps({
|
||||
ref: inputRef,
|
||||
...restInputProps,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
}) => {
|
||||
const wrappedGetItemProps = itemProps => getItemProps({ ...itemProps });
|
||||
return (
|
||||
// Super annoying but it works best to return a div so we
|
||||
// can't use a View here, but we can fake it be using the
|
||||
// className
|
||||
<div
|
||||
className={cx('view', css({ display: 'flex' }))}
|
||||
{...containerProps}
|
||||
>
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
(() => {
|
||||
const { className, style, ...restInputProps } =
|
||||
inputProps || {};
|
||||
const downshiftProps = getInputProps({
|
||||
ref: inputRef,
|
||||
...restInputProps,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
|
||||
if (openOnFocus) {
|
||||
open();
|
||||
}
|
||||
},
|
||||
onBlur: e => {
|
||||
// Should this be e.nativeEvent
|
||||
e['preventDownshiftDefault'] = true;
|
||||
inputProps.onBlur?.(e);
|
||||
if (openOnFocus) {
|
||||
open();
|
||||
}
|
||||
},
|
||||
onBlur: e => {
|
||||
// Should this be e.nativeEvent
|
||||
e['preventDownshiftDefault'] = true;
|
||||
inputProps.onBlur?.(e);
|
||||
|
||||
if (!closeOnBlur) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemsViewRef.current?.contains(e.relatedTarget)) {
|
||||
// Do not close when the user clicks on any of the items.
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (clearOnBlur) {
|
||||
if (e.target.value === '') {
|
||||
onSelect?.(null, e.target.value);
|
||||
setSelectedItem(null);
|
||||
close();
|
||||
if (!closeOnBlur) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If not using table behavior, reset the input on blur. Tables
|
||||
// handle saving the value on blur.
|
||||
const value = selectedItem
|
||||
? getItemId(selectedItem)
|
||||
: null;
|
||||
|
||||
resetState(value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { onKeyDown } = inputProps || {};
|
||||
|
||||
// If the dropdown is open, an item is highlighted, and the user
|
||||
// pressed enter, always capture that and handle it ourselves
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
if (highlightedIndex != null) {
|
||||
if (
|
||||
inst.lastChangeType ===
|
||||
Downshift.stateChangeTypes.itemMouseEnter
|
||||
) {
|
||||
// If the last thing the user did was hover an item, intentionally
|
||||
// ignore the default behavior of selecting the item. It's too
|
||||
// common to accidentally hover an item and then save it
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Otherwise, stop propagation so that the table navigator
|
||||
// doesn't handle it
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (!strict) {
|
||||
// Handle it ourselves
|
||||
e.stopPropagation();
|
||||
onSelect(value, (e.target as HTMLInputElement).value);
|
||||
return onSelectAfter();
|
||||
} else {
|
||||
// No highlighted item, still allow the table to save the item
|
||||
// as `null`, even though we're allowing the table to move
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
} else if (shouldSaveFromKey(e)) {
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape ourselves
|
||||
if (e.key === 'Escape') {
|
||||
e.nativeEvent['preventDownshiftDefault'] = true;
|
||||
|
||||
if (!embedded && isOpen) {
|
||||
if (itemsViewRef.current?.contains(e.relatedTarget)) {
|
||||
// Do not close when the user clicks on any of the items.
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
fireUpdate(
|
||||
onUpdate,
|
||||
strict,
|
||||
suggestions,
|
||||
null,
|
||||
getItemId(originalItem),
|
||||
);
|
||||
if (clearOnBlur) {
|
||||
if (e.target.value === '') {
|
||||
onSelect?.(null, e.target.value);
|
||||
setSelectedItem(null);
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(getItemName(originalItem));
|
||||
setSelectedItem(
|
||||
findItem(strict, suggestions, originalItem),
|
||||
);
|
||||
setHighlightedIndex(null);
|
||||
if (embedded) {
|
||||
open();
|
||||
// If not using table behavior, reset the input on blur. Tables
|
||||
// handle saving the value on blur.
|
||||
const value = selectedItem
|
||||
? getItemId(selectedItem)
|
||||
: null;
|
||||
|
||||
resetState(value);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const { onKeyDown } = inputProps || {};
|
||||
|
||||
return {
|
||||
...downshiftProps,
|
||||
...(className && { className }),
|
||||
...(style && { style }),
|
||||
};
|
||||
})(),
|
||||
)}
|
||||
</View>
|
||||
{isOpen &&
|
||||
filtered.length > 0 &&
|
||||
(embedded ? (
|
||||
<View
|
||||
ref={itemsViewRef}
|
||||
style={{ ...styles.darkScrollbar, marginTop: 5 }}
|
||||
data-testid="autocomplete"
|
||||
>
|
||||
{renderItems(
|
||||
filtered,
|
||||
getItemProps,
|
||||
highlightedIndex,
|
||||
inputValue,
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom start"
|
||||
offset={2}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={close}
|
||||
isNonModal
|
||||
style={{
|
||||
...styles.darkScrollbar,
|
||||
...styles.popover,
|
||||
backgroundColor: theme.menuAutoCompleteBackground,
|
||||
color: theme.menuAutoCompleteText,
|
||||
minWidth: 200,
|
||||
width: triggerRef.current?.clientWidth,
|
||||
}}
|
||||
data-testid="autocomplete"
|
||||
>
|
||||
<View ref={itemsViewRef}>
|
||||
// If the dropdown is open, an item is highlighted, and the user
|
||||
// pressed enter, always capture that and handle it ourselves
|
||||
if (isOpen) {
|
||||
if (e.key === 'Enter') {
|
||||
if (highlightedIndex != null) {
|
||||
if (
|
||||
inst.lastChangeType ===
|
||||
Downshift.stateChangeTypes.itemMouseEnter
|
||||
) {
|
||||
// If the last thing the user did was hover an item, intentionally
|
||||
// ignore the default behavior of selecting the item. It's too
|
||||
// common to accidentally hover an item and then save it
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// Otherwise, stop propagation so that the table navigator
|
||||
// doesn't handle it
|
||||
e.stopPropagation();
|
||||
}
|
||||
} else if (!strict) {
|
||||
// Handle it ourselves
|
||||
e.stopPropagation();
|
||||
onSelect(
|
||||
value,
|
||||
(e.target as HTMLInputElement).value,
|
||||
);
|
||||
return onSelectAfter();
|
||||
} else {
|
||||
// No highlighted item, still allow the table to save the item
|
||||
// as `null`, even though we're allowing the table to move
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
} else if (shouldSaveFromKey(e)) {
|
||||
e.preventDefault();
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle escape ourselves
|
||||
if (e.key === 'Escape') {
|
||||
e.nativeEvent['preventDownshiftDefault'] = true;
|
||||
|
||||
if (!embedded && isOpen) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
fireUpdate(
|
||||
onUpdate,
|
||||
strict,
|
||||
suggestions,
|
||||
null,
|
||||
getItemId(originalItem),
|
||||
);
|
||||
|
||||
setValue(getItemName(originalItem));
|
||||
setSelectedItem(
|
||||
findItem(strict, suggestions, originalItem),
|
||||
);
|
||||
setHighlightedIndex(null);
|
||||
if (embedded) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...downshiftProps,
|
||||
...(className && { className }),
|
||||
...(style && { style }),
|
||||
};
|
||||
})(),
|
||||
)}
|
||||
</View>
|
||||
{isOpen &&
|
||||
filtered.length > 0 &&
|
||||
(embedded ? (
|
||||
<View
|
||||
ref={itemsViewRef}
|
||||
style={{ ...styles.darkScrollbar, marginTop: 5 }}
|
||||
data-testid="autocomplete"
|
||||
>
|
||||
{renderItems(
|
||||
filtered,
|
||||
getItemProps,
|
||||
wrappedGetItemProps,
|
||||
highlightedIndex,
|
||||
inputValue,
|
||||
)}
|
||||
</View>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom start"
|
||||
offset={2}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={close}
|
||||
isNonModal
|
||||
style={{
|
||||
...styles.darkScrollbar,
|
||||
...styles.popover,
|
||||
backgroundColor: theme.menuAutoCompleteBackground,
|
||||
color: theme.menuAutoCompleteText,
|
||||
minWidth: 200,
|
||||
width: triggerRef.current?.clientWidth,
|
||||
}}
|
||||
data-testid="autocomplete"
|
||||
>
|
||||
<View ref={itemsViewRef}>
|
||||
{renderItems(
|
||||
filtered,
|
||||
wrappedGetItemProps,
|
||||
highlightedIndex,
|
||||
inputValue,
|
||||
)}
|
||||
</View>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Downshift>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { Screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { generateAccount } from 'loot-core/mocks';
|
||||
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
||||
import type {
|
||||
AccountEntity,
|
||||
NearbyPayeeEntity,
|
||||
PayeeEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { PayeeAutocomplete } from './PayeeAutocomplete';
|
||||
import type { PayeeAutocompleteProps } from './PayeeAutocomplete';
|
||||
|
||||
import { AuthProvider } from '@desktop-client/auth/AuthProvider';
|
||||
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
|
||||
import { createTestQueryClient, TestProviders } from '@desktop-client/mocks';
|
||||
import { payeeQueries } from '@desktop-client/payees';
|
||||
|
||||
const PAYEE_SELECTOR = '[data-testid][role=option]';
|
||||
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';
|
||||
const ALL_PAYEE_ITEMS_SELECTOR = '[data-testid$="-payee-item"]';
|
||||
|
||||
const payees = [
|
||||
makePayee('Bob', { favorite: true }),
|
||||
@@ -41,7 +48,30 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
|
||||
};
|
||||
}
|
||||
|
||||
function extractPayeesAndHeaderNames(screen: Screen) {
|
||||
function makeNearbyPayee(name: string, distance: number): NearbyPayeeEntity {
|
||||
const id = name.toLowerCase() + '-id';
|
||||
return {
|
||||
payee: {
|
||||
id,
|
||||
name,
|
||||
favorite: false,
|
||||
transfer_acct: undefined,
|
||||
},
|
||||
location: {
|
||||
id: id + '-loc',
|
||||
payee_id: id,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
created_at: 0,
|
||||
distance,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractPayeesAndHeaderNames(
|
||||
screen: Screen,
|
||||
itemSelector: string = PAYEE_SELECTOR,
|
||||
) {
|
||||
const autocompleteElement = screen.getByTestId('autocomplete');
|
||||
|
||||
// Get all elements that match either selector, but query them separately
|
||||
@@ -49,7 +79,7 @@ function extractPayeesAndHeaderNames(screen: Screen) {
|
||||
const headers = [
|
||||
...autocompleteElement.querySelectorAll(PAYEE_SECTION_SELECTOR),
|
||||
];
|
||||
const items = [...autocompleteElement.querySelectorAll(PAYEE_SELECTOR)];
|
||||
const items = [...autocompleteElement.querySelectorAll(itemSelector)];
|
||||
|
||||
// Combine all elements and sort by their position in the DOM
|
||||
const allElements = [...headers, ...items];
|
||||
@@ -78,14 +108,52 @@ async function clickAutocomplete(autocomplete: HTMLElement) {
|
||||
await waitForAutocomplete();
|
||||
}
|
||||
|
||||
vi.mock('@desktop-client/hooks/useNearbyPayees', () => ({
|
||||
useNearbyPayees: vi.fn(),
|
||||
}));
|
||||
|
||||
function firstOrIncorrect(id: string | null): string {
|
||||
return id?.split('-', 1)[0] || 'incorrect';
|
||||
}
|
||||
|
||||
function mockNearbyPayeesResult(
|
||||
data: NearbyPayeeEntity[],
|
||||
): UseQueryResult<NearbyPayeeEntity[], Error> {
|
||||
return {
|
||||
data,
|
||||
dataUpdatedAt: 0,
|
||||
error: null,
|
||||
errorUpdatedAt: 0,
|
||||
errorUpdateCount: 0,
|
||||
failureCount: 0,
|
||||
failureReason: null,
|
||||
fetchStatus: 'idle',
|
||||
isError: false,
|
||||
isFetched: true,
|
||||
isFetchedAfterMount: true,
|
||||
isFetching: false,
|
||||
isInitialLoading: false,
|
||||
isLoading: false,
|
||||
isLoadingError: false,
|
||||
isPaused: false,
|
||||
isPending: false,
|
||||
isPlaceholderData: false,
|
||||
isRefetchError: false,
|
||||
isRefetching: false,
|
||||
isStale: false,
|
||||
isSuccess: true,
|
||||
isEnabled: true,
|
||||
promise: Promise.resolve(data),
|
||||
refetch: vi.fn(),
|
||||
status: 'success',
|
||||
};
|
||||
}
|
||||
|
||||
describe('PayeeAutocomplete.getPayeeSuggestions', () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useNearbyPayees).mockReturnValue(mockNearbyPayeesResult([]));
|
||||
queryClient.setQueryData(payeeQueries.listCommon().queryKey, []);
|
||||
});
|
||||
|
||||
@@ -207,6 +275,108 @@ describe('PayeeAutocomplete.getPayeeSuggestions', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('nearby payees appear in their own section before other payees', async () => {
|
||||
const nearbyPayees = [
|
||||
makeNearbyPayee('Coffee Shop', 0.3),
|
||||
makeNearbyPayee('Grocery Store', 1.2),
|
||||
];
|
||||
const payees = [makePayee('Alice'), makePayee('Bob')];
|
||||
vi.mocked(useNearbyPayees).mockReturnValue(
|
||||
mockNearbyPayeesResult(nearbyPayees),
|
||||
);
|
||||
|
||||
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
|
||||
|
||||
expect(
|
||||
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
|
||||
).toStrictEqual([
|
||||
'Nearby Payees',
|
||||
'Coffee Shop',
|
||||
'Grocery Store',
|
||||
'Payees',
|
||||
'Alice',
|
||||
'Bob',
|
||||
]);
|
||||
});
|
||||
|
||||
test('nearby payees are filtered by search input', async () => {
|
||||
const nearbyPayees = [
|
||||
makeNearbyPayee('Coffee Shop', 0.3),
|
||||
makeNearbyPayee('Grocery Store', 1.2),
|
||||
];
|
||||
const payees = [makePayee('Alice'), makePayee('Bob')];
|
||||
vi.mocked(useNearbyPayees).mockReturnValue(
|
||||
mockNearbyPayeesResult(nearbyPayees),
|
||||
);
|
||||
|
||||
const autocomplete = renderPayeeAutocomplete({ payees });
|
||||
await clickAutocomplete(autocomplete);
|
||||
|
||||
const input = autocomplete.querySelector('input')!;
|
||||
await userEvent.type(input, 'Coffee');
|
||||
await waitForAutocomplete();
|
||||
|
||||
const names = extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR);
|
||||
expect(names).toContain('Nearby Payees');
|
||||
expect(names).toContain('Coffee Shop');
|
||||
expect(names).not.toContain('Grocery Store');
|
||||
expect(names).not.toContain('Alice');
|
||||
expect(names).not.toContain('Bob');
|
||||
});
|
||||
|
||||
test('nearby payees coexist with favorites and common payees', async () => {
|
||||
const nearbyPayees = [makeNearbyPayee('Coffee Shop', 0.3)];
|
||||
const payees = [
|
||||
makePayee('Alice'),
|
||||
makePayee('Bob'),
|
||||
makePayee('Eve', { favorite: true }),
|
||||
makePayee('Carol'),
|
||||
];
|
||||
vi.mocked(useNearbyPayees).mockReturnValue(
|
||||
mockNearbyPayeesResult(nearbyPayees),
|
||||
);
|
||||
queryClient.setQueryData(payeeQueries.listCommon().queryKey, [
|
||||
makePayee('Bob'),
|
||||
makePayee('Carol'),
|
||||
]);
|
||||
|
||||
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
|
||||
|
||||
expect(
|
||||
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
|
||||
).toStrictEqual([
|
||||
'Nearby Payees',
|
||||
'Coffee Shop',
|
||||
'Suggested Payees',
|
||||
'Eve',
|
||||
'Bob',
|
||||
'Carol',
|
||||
'Payees',
|
||||
'Alice',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a payee appearing in both nearby and favorites shows in both sections', async () => {
|
||||
const nearbyPayees = [makeNearbyPayee('Eve', 0.5)];
|
||||
const payees = [makePayee('Alice'), makePayee('Eve', { favorite: true })];
|
||||
vi.mocked(useNearbyPayees).mockReturnValue(
|
||||
mockNearbyPayeesResult(nearbyPayees),
|
||||
);
|
||||
|
||||
await clickAutocomplete(renderPayeeAutocomplete({ payees }));
|
||||
|
||||
expect(
|
||||
extractPayeesAndHeaderNames(screen, ALL_PAYEE_ITEMS_SELECTOR),
|
||||
).toStrictEqual([
|
||||
'Nearby Payees',
|
||||
'Eve',
|
||||
'Suggested Payees',
|
||||
'Eve',
|
||||
'Payees',
|
||||
'Alice',
|
||||
]);
|
||||
});
|
||||
|
||||
test('list with no favorites shows just the payees list', async () => {
|
||||
//Note that the payees list assumes the payees are already sorted
|
||||
const payees = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { Fragment, useMemo, useState } from 'react';
|
||||
import React, { Fragment, useCallback, useMemo, useState } from 'react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentPropsWithoutRef,
|
||||
@@ -13,15 +13,24 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { SvgAdd, SvgBookmark } from '@actual-app/components/icons/v1';
|
||||
import {
|
||||
SvgAdd,
|
||||
SvgBookmark,
|
||||
SvgLocation,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { TextOneLine } from '@actual-app/components/text-one-line';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { formatDistance } from 'loot-core/shared/location-utils';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
||||
import type {
|
||||
AccountEntity,
|
||||
NearbyPayeeEntity,
|
||||
PayeeEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
Autocomplete,
|
||||
@@ -32,13 +41,19 @@ import { ItemHeader } from './ItemHeader';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCommonPayees } from '@desktop-client/hooks/useCommonPayees';
|
||||
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import {
|
||||
getActivePayees,
|
||||
useCreatePayeeMutation,
|
||||
useDeletePayeeLocationMutation,
|
||||
} from '@desktop-client/payees';
|
||||
|
||||
type PayeeAutocompleteItem = PayeeEntity & PayeeItemType;
|
||||
type PayeeAutocompleteItem = PayeeEntity &
|
||||
PayeeItemType & {
|
||||
nearbyLocationId?: string;
|
||||
distance?: number;
|
||||
};
|
||||
|
||||
const MAX_AUTO_SUGGESTIONS = 5;
|
||||
|
||||
@@ -130,17 +145,25 @@ type PayeeListProps = {
|
||||
props: ComponentPropsWithoutRef<typeof PayeeItem>,
|
||||
) => ReactNode;
|
||||
footer: ReactNode;
|
||||
onForgetLocation?: (locationId: string) => void;
|
||||
};
|
||||
|
||||
type ItemTypes = 'account' | 'payee' | 'common_payee';
|
||||
type ItemTypes = 'account' | 'payee' | 'common_payee' | 'nearby_payee';
|
||||
type PayeeItemType = {
|
||||
itemType: ItemTypes;
|
||||
};
|
||||
|
||||
function determineItemType(item: PayeeEntity, isCommon: boolean): ItemTypes {
|
||||
function determineItemType(
|
||||
item: PayeeEntity,
|
||||
isCommon: boolean,
|
||||
isNearby: boolean = false,
|
||||
): ItemTypes {
|
||||
if (item.transfer_acct) {
|
||||
return 'account';
|
||||
}
|
||||
if (isNearby) {
|
||||
return 'nearby_payee';
|
||||
}
|
||||
if (isCommon) {
|
||||
return 'common_payee';
|
||||
} else {
|
||||
@@ -158,6 +181,7 @@ function PayeeList({
|
||||
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
|
||||
renderPayeeItem = defaultRenderPayeeItem,
|
||||
footer,
|
||||
onForgetLocation,
|
||||
}: PayeeListProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -165,56 +189,66 @@ function PayeeList({
|
||||
// with the value of the input so it always shows whatever the user
|
||||
// entered
|
||||
|
||||
const { newPayee, suggestedPayees, payees, transferPayees } = useMemo(() => {
|
||||
let currentIndex = 0;
|
||||
const result = items.reduce(
|
||||
(acc, item) => {
|
||||
if (item.id === 'new') {
|
||||
acc.newPayee = { ...item };
|
||||
} else if (item.itemType === 'common_payee') {
|
||||
acc.suggestedPayees.push({ ...item });
|
||||
} else if (item.itemType === 'payee') {
|
||||
acc.payees.push({ ...item });
|
||||
} else if (item.itemType === 'account') {
|
||||
acc.transferPayees.push({ ...item });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
newPayee: null as PayeeAutocompleteItem | null,
|
||||
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
payees: [] as Array<PayeeAutocompleteItem>,
|
||||
transferPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
},
|
||||
);
|
||||
const { newPayee, suggestedPayees, payees, transferPayees, nearbyPayees } =
|
||||
useMemo(() => {
|
||||
let currentIndex = 0;
|
||||
const result = items.reduce(
|
||||
(acc, item) => {
|
||||
if (item.id === 'new') {
|
||||
acc.newPayee = { ...item };
|
||||
} else if (item.itemType === 'common_payee') {
|
||||
acc.suggestedPayees.push({ ...item });
|
||||
} else if (item.itemType === 'payee') {
|
||||
acc.payees.push({ ...item });
|
||||
} else if (item.itemType === 'account') {
|
||||
acc.transferPayees.push({ ...item });
|
||||
} else if (item.itemType === 'nearby_payee') {
|
||||
acc.nearbyPayees.push({ ...item });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{
|
||||
newPayee: null as PayeeAutocompleteItem | null,
|
||||
nearbyPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
suggestedPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
payees: [] as Array<PayeeAutocompleteItem>,
|
||||
transferPayees: [] as Array<PayeeAutocompleteItem>,
|
||||
},
|
||||
);
|
||||
|
||||
// assign indexes in render order
|
||||
const newPayeeWithIndex = result.newPayee
|
||||
? { ...result.newPayee, highlightedIndex: currentIndex++ }
|
||||
: null;
|
||||
// assign indexes in render order
|
||||
const newPayeeWithIndex = result.newPayee
|
||||
? { ...result.newPayee, highlightedIndex: currentIndex++ }
|
||||
: null;
|
||||
|
||||
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
const nearbyPayeesWithIndex = result.nearbyPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
const payeesWithIndex = result.payees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
const suggestedPayeesWithIndex = result.suggestedPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
const transferPayeesWithIndex = result.transferPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
const payeesWithIndex = result.payees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
return {
|
||||
newPayee: newPayeeWithIndex,
|
||||
suggestedPayees: suggestedPayeesWithIndex,
|
||||
payees: payeesWithIndex,
|
||||
transferPayees: transferPayeesWithIndex,
|
||||
};
|
||||
}, [items]);
|
||||
const transferPayeesWithIndex = result.transferPayees.map(item => ({
|
||||
...item,
|
||||
highlightedIndex: currentIndex++,
|
||||
}));
|
||||
|
||||
return {
|
||||
newPayee: newPayeeWithIndex,
|
||||
nearbyPayees: nearbyPayeesWithIndex,
|
||||
suggestedPayees: suggestedPayeesWithIndex,
|
||||
payees: payeesWithIndex,
|
||||
transferPayees: transferPayeesWithIndex,
|
||||
};
|
||||
}, [items]);
|
||||
|
||||
// We limit the number of payees shown to 100.
|
||||
// So we show a hint that more are available via search.
|
||||
@@ -237,6 +271,20 @@ function PayeeList({
|
||||
embedded,
|
||||
})}
|
||||
|
||||
{nearbyPayees.length > 0 &&
|
||||
renderPayeeItemGroupHeader({ title: t('Nearby Payees') })}
|
||||
{nearbyPayees.map(item => (
|
||||
<Fragment key={item.id}>
|
||||
<NearbyPayeeItem
|
||||
{...(getItemProps ? getItemProps({ item }) : {})}
|
||||
item={item}
|
||||
highlighted={highlightedIndex === item.highlightedIndex}
|
||||
embedded={embedded}
|
||||
onForgetLocation={onForgetLocation}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
{suggestedPayees.length > 0 &&
|
||||
renderPayeeItemGroupHeader({ title: t('Suggested Payees') })}
|
||||
{suggestedPayees.map(item => (
|
||||
@@ -324,6 +372,7 @@ export type PayeeAutocompleteProps = ComponentProps<
|
||||
) => ReactElement<typeof PayeeItem>;
|
||||
accounts?: AccountEntity[];
|
||||
payees?: PayeeEntity[];
|
||||
nearbyPayees?: NearbyPayeeEntity[];
|
||||
};
|
||||
|
||||
export function PayeeAutocomplete({
|
||||
@@ -343,16 +392,22 @@ export function PayeeAutocomplete({
|
||||
renderPayeeItem = defaultRenderPayeeItem,
|
||||
accounts,
|
||||
payees,
|
||||
nearbyPayees,
|
||||
...props
|
||||
}: PayeeAutocompleteProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: commonPayees } = useCommonPayees();
|
||||
const { data: retrievedPayees = [] } = usePayees();
|
||||
const { data: retrievedNearbyPayees = [] } = useNearbyPayees();
|
||||
if (!payees) {
|
||||
payees = retrievedPayees;
|
||||
}
|
||||
const createPayeeMutation = useCreatePayeeMutation();
|
||||
const deletePayeeLocationMutation = useDeletePayeeLocationMutation();
|
||||
|
||||
if (!nearbyPayees) {
|
||||
nearbyPayees = retrievedNearbyPayees;
|
||||
}
|
||||
|
||||
const { data: cachedAccounts = [] } = useAccounts();
|
||||
if (!accounts) {
|
||||
@@ -392,6 +447,43 @@ export function PayeeAutocomplete({
|
||||
showInactivePayees,
|
||||
]);
|
||||
|
||||
// Process nearby payees separately from suggestions
|
||||
const nearbyPayeesWithType: PayeeAutocompleteItem[] = useMemo(() => {
|
||||
if (!nearbyPayees?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const processed: PayeeAutocompleteItem[] = nearbyPayees.map(result => ({
|
||||
...result.payee,
|
||||
itemType: 'nearby_payee' as const,
|
||||
nearbyLocationId: result.location.id,
|
||||
distance: result.location.distance,
|
||||
}));
|
||||
return processed;
|
||||
}, [nearbyPayees]);
|
||||
|
||||
// Filter nearby payees based on input value (similar to regular payees)
|
||||
const filteredNearbyPayees = useMemo(() => {
|
||||
if (!nearbyPayeesWithType.length || !rawPayee) {
|
||||
return nearbyPayeesWithType;
|
||||
}
|
||||
|
||||
return nearbyPayeesWithType.filter(payee => {
|
||||
return defaultFilterSuggestion(payee, rawPayee);
|
||||
});
|
||||
}, [nearbyPayeesWithType, rawPayee]);
|
||||
|
||||
const handleForgetLocation = useCallback(
|
||||
async (locationId: string) => {
|
||||
try {
|
||||
await deletePayeeLocationMutation.mutateAsync(locationId);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete payee location', { error });
|
||||
}
|
||||
},
|
||||
[deletePayeeLocationMutation],
|
||||
);
|
||||
|
||||
async function handleSelect(idOrIds, rawInputValue) {
|
||||
if (!clearOnBlur) {
|
||||
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
|
||||
@@ -480,6 +572,12 @@ export function PayeeAutocomplete({
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
getHighlightedIndex={suggestions => {
|
||||
// If we have nearby payees, highlight the first nearby payee
|
||||
if (filteredNearbyPayees.length > 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Otherwise use original logic for suggestions
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
} else if (suggestions[0].id === 'new') {
|
||||
@@ -491,7 +589,7 @@ export function PayeeAutocomplete({
|
||||
filterSuggestions={filterSuggestions}
|
||||
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
|
||||
<PayeeList
|
||||
items={items}
|
||||
items={[...filteredNearbyPayees, ...items]}
|
||||
commonPayees={commonPayees}
|
||||
getItemProps={getItemProps}
|
||||
highlightedIndex={highlightedIndex}
|
||||
@@ -521,6 +619,7 @@ export function PayeeAutocomplete({
|
||||
)}
|
||||
</AutocompleteFooter>
|
||||
}
|
||||
onForgetLocation={handleForgetLocation}
|
||||
/>
|
||||
)}
|
||||
{...props}
|
||||
@@ -698,3 +797,126 @@ function defaultRenderPayeeItem(
|
||||
): ReactElement<typeof PayeeItem> {
|
||||
return <PayeeItem {...props} />;
|
||||
}
|
||||
|
||||
type NearbyPayeeItemProps = PayeeItemProps & {
|
||||
onForgetLocation?: (locationId: string) => void;
|
||||
};
|
||||
|
||||
function NearbyPayeeItem({
|
||||
item,
|
||||
className,
|
||||
highlighted,
|
||||
embedded,
|
||||
onForgetLocation,
|
||||
...props
|
||||
}: NearbyPayeeItemProps) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
const narrowStyle = isNarrowWidth
|
||||
? {
|
||||
...styles.mobileMenuItem,
|
||||
borderRadius: 0,
|
||||
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;
|
||||
}
|
||||
|
||||
// Extract location ID and distance from the nearby payee item
|
||||
const locationId = item.nearbyLocationId;
|
||||
const distance = item.distance;
|
||||
const distanceText = distance !== undefined ? formatDistance(distance) : '';
|
||||
|
||||
const handleForgetClick = () => {
|
||||
if (locationId && onForgetLocation) {
|
||||
onForgetLocation(locationId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
className,
|
||||
css({
|
||||
backgroundColor: highlighted
|
||||
? theme.menuAutoCompleteBackgroundHover
|
||||
: 'transparent',
|
||||
color: highlighted
|
||||
? theme.menuAutoCompleteItemTextHover
|
||||
: theme.menuAutoCompleteItemText,
|
||||
borderRadius: embedded ? 4 : 0,
|
||||
padding: 4,
|
||||
paddingLeft: paddingLeftOverFromIcon,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
...narrowStyle,
|
||||
}),
|
||||
)}
|
||||
data-testid={`${item.name}-payee-item`}
|
||||
data-highlighted={highlighted || undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
font: 'inherit',
|
||||
color: 'inherit',
|
||||
textAlign: 'left',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<TextOneLine>
|
||||
{itemIcon}
|
||||
{item.name}
|
||||
</TextOneLine>
|
||||
{distanceText && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
color: highlighted
|
||||
? theme.menuAutoCompleteItemTextHover
|
||||
: theme.pageTextSubdued,
|
||||
marginLeft: itemIcon ? iconSize + 5 : 0,
|
||||
}}
|
||||
>
|
||||
{distanceText}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{locationId && (
|
||||
<Button
|
||||
variant="menu"
|
||||
onPress={handleForgetClick}
|
||||
style={{
|
||||
backgroundColor: theme.errorBackground,
|
||||
border: `1px solid ${theme.errorBorder}`,
|
||||
color: theme.pageText,
|
||||
fontSize: '11px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<Trans i18nKey="forget">Forget</Trans>
|
||||
<SvgLocation width={10} height={10} style={{ marginLeft: 4 }} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,13 +195,13 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
name="synced-account-edit"
|
||||
containerProps={{ style: { width: 800 } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('{{accountName}} bank sync settings', {
|
||||
accountName: potentiallyTruncatedAccountName,
|
||||
})}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
|
||||
<Text style={{ fontSize: 15 }}>
|
||||
@@ -246,20 +246,20 @@ export function EditSyncAccount({ account }: EditSyncAccountProps) {
|
||||
<Button
|
||||
style={{ color: theme.errorText }}
|
||||
onPress={() => {
|
||||
void onUnlink(close);
|
||||
void onUnlink(() => state.close());
|
||||
}}
|
||||
>
|
||||
<Trans>Unlink account</Trans>
|
||||
</Button>
|
||||
|
||||
<SpaceBetween gap={10}>
|
||||
<Button onPress={close}>
|
||||
<Button onPress={() => state.close()}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
void onSave(close);
|
||||
void onSave(() => state.close());
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function BudgetSummaries() {
|
||||
}
|
||||
|
||||
const to = -offsetX;
|
||||
spring.start({ from: { x: from }, x: to });
|
||||
void spring.start({ from: { x: from }, x: to });
|
||||
}, [spring, firstMonth, monthWidth, allMonths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -63,7 +63,7 @@ export function BudgetSummaries() {
|
||||
}, [firstMonth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
|
||||
void spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
|
||||
}, [spring, monthWidth]);
|
||||
|
||||
const { SummaryComponent } = useBudgetComponents();
|
||||
|
||||
@@ -45,7 +45,7 @@ export function IncomeMenu({
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu option: ${name}`);
|
||||
throw new Error(`Unrecognized menu option: ${String(name)}`);
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { Warning } from '@desktop-client/components/alerts';
|
||||
@@ -19,13 +18,17 @@ export function RefillAutomation({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SpaceBetween direction="vertical" gap={10} style={{ marginTop: 10 }}>
|
||||
<Text>
|
||||
<Trans>Uses the balance limit automation for this category.</Trans>
|
||||
</Text>
|
||||
<SpaceBetween align="center" gap={10} style={{ marginTop: 10 }}>
|
||||
{!hasLimitAutomation && (
|
||||
<Warning>
|
||||
<SpaceBetween gap={10} align="center" style={{ flexWrap: 'wrap' }}>
|
||||
<Warning
|
||||
style={{ width: '100%', alignItems: 'center' }}
|
||||
iconStyle={{ alignSelf: 'unset', paddingTop: 0, marginTop: -2 }}
|
||||
>
|
||||
<SpaceBetween
|
||||
gap={10}
|
||||
align="center"
|
||||
style={{ width: '100%', justifyContent: 'space-between' }}
|
||||
>
|
||||
<View>
|
||||
<Trans>
|
||||
Add a balance limit automation to set the refill target.
|
||||
|
||||
@@ -68,7 +68,9 @@ export const getInitialState = (template: Template | null): ReducerState => {
|
||||
case 'error':
|
||||
throw new Error('An error occurred while parsing the template');
|
||||
default:
|
||||
throw new Error(`Unknown template type: ${type satisfies undefined}`);
|
||||
throw new Error(
|
||||
`Unknown template type: ${String(type satisfies undefined)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,7 +170,9 @@ const changeType = (
|
||||
};
|
||||
default:
|
||||
// Make sure we're not missing any cases
|
||||
throw new Error(`Unknown display type: ${visualType satisfies never}`);
|
||||
throw new Error(
|
||||
`Unknown display type: ${String(visualType satisfies never)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -251,6 +255,6 @@ export const templateReducer = (
|
||||
return mapTemplateTypesForUpdate(state, action.payload);
|
||||
default:
|
||||
// Make sure we're not missing any cases
|
||||
throw new Error(`Unknown display type: ${type satisfies never}`);
|
||||
throw new Error(`Unknown display type: ${String(type satisfies never)}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ export function BalanceMenu({
|
||||
onCarryover?.(!carryover);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu option: ${name}`);
|
||||
throw new Error(`Unrecognized menu option: ${String(name)}`);
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
|
||||
@@ -131,9 +131,23 @@ export function FilterExpression<T extends RuleConditionEntity>({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
element instanceof HTMLElement &&
|
||||
(element.closest('[data-testid="account-autocomplete-modal"]') ||
|
||||
element.closest('[data-testid="payee-autocomplete-modal"]') ||
|
||||
element.closest('[data-testid="category-autocomplete-modal"]'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
style={{ width: 275, padding: 15, color: theme.menuItemText }}
|
||||
style={{
|
||||
width: 275,
|
||||
padding: 15,
|
||||
color: theme.menuItemText,
|
||||
zIndex: '2500 !important',
|
||||
}}
|
||||
data-testid="filters-menu-tooltip"
|
||||
>
|
||||
<FilterEditor
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
getFieldError,
|
||||
getValidOps,
|
||||
mapField,
|
||||
unparse,
|
||||
unparseConditions,
|
||||
} from 'loot-core/shared/rules';
|
||||
import { titleFirst } from 'loot-core/shared/util';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
@@ -296,37 +296,39 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
field={field === 'date' || field === 'category' ? subfield : field}
|
||||
// @ts-expect-error - fix me
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
// @ts-expect-error - fix me
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type &&
|
||||
type !== 'boolean' &&
|
||||
(field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
field={
|
||||
field === 'date' || field === 'category' ? subfield : field
|
||||
}
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ??
|
||||
(op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field === 'payee' && isPayeeIdOp(op) && (
|
||||
<PayeeFilter
|
||||
@@ -424,7 +426,7 @@ export function FilterButton<T extends RuleConditionEntity>({
|
||||
|
||||
async function onValidateAndApply(cond: T) {
|
||||
// @ts-expect-error - fix me
|
||||
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
cond = unparseConditions({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
|
||||
if (cond.type === 'date' && cond.options) {
|
||||
if (cond.options.month) {
|
||||
@@ -518,7 +520,7 @@ export function FilterButton<T extends RuleConditionEntity>({
|
||||
items={[
|
||||
...translatedFilterFields
|
||||
.filter(f => (exclude ? !exclude.includes(f[0]) : true))
|
||||
.sort()
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([name, text]) => ({
|
||||
name,
|
||||
text: titleFirst(text),
|
||||
@@ -555,9 +557,23 @@ export function FilterButton<T extends RuleConditionEntity>({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
element instanceof HTMLElement &&
|
||||
(element.closest('[data-testid="account-autocomplete-modal"]') ||
|
||||
element.closest('[data-testid="payee-autocomplete-modal"]') ||
|
||||
element.closest('[data-testid="category-autocomplete-modal"]'))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
style={{ width: 275, padding: 15, color: theme.menuItemText }}
|
||||
style={{
|
||||
width: 275,
|
||||
padding: 15,
|
||||
color: theme.menuItemText,
|
||||
zIndex: '2500 !important',
|
||||
}}
|
||||
data-testid="filters-menu-tooltip"
|
||||
>
|
||||
{state.field && (
|
||||
@@ -614,7 +630,11 @@ export function FilterEditor<T extends RuleConditionEntity>({
|
||||
dispatch={dispatch}
|
||||
onApply={cond => {
|
||||
// @ts-expect-error - fix me
|
||||
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
cond = unparseConditions({
|
||||
...cond,
|
||||
// @ts-expect-error - fix me
|
||||
type: FIELD_TYPES.get(cond.field),
|
||||
});
|
||||
|
||||
if (cond.type === 'date' && cond.options) {
|
||||
if (
|
||||
|
||||
@@ -313,7 +313,7 @@ export function ConfigServer() {
|
||||
switch (error) {
|
||||
case 'network-failure':
|
||||
return t(
|
||||
'Server is not running at this URL. Make sure you have HTTPS set up properly.',
|
||||
'Connection failed. If you use a self-signed certificate or were recently offline, try refreshing the page. Otherwise ensure you have HTTPS set up properly.',
|
||||
);
|
||||
default:
|
||||
return t(
|
||||
|
||||
@@ -47,7 +47,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
|
||||
if (active) {
|
||||
dragStartedRef.current = true;
|
||||
api.start({
|
||||
void api.start({
|
||||
x: Math.max(-actionsWidth, Math.min(0, currentX)),
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
@@ -61,7 +61,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
currentX < -actionsWidth / 2 ||
|
||||
(vx < -0.5 && currentX < -actionsWidth / 5);
|
||||
|
||||
api.start({
|
||||
void api.start({
|
||||
x: shouldReveal ? -actionsWidth : 0,
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
@@ -140,7 +140,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
{typeof actions === 'function'
|
||||
? actions({
|
||||
close: () => {
|
||||
api.start({
|
||||
void api.start({
|
||||
x: 0,
|
||||
onRest: () => {
|
||||
setIsRevealed(false);
|
||||
|
||||
@@ -79,6 +79,7 @@ InputField.displayName = 'InputField';
|
||||
|
||||
type TapFieldProps = ComponentPropsWithRef<typeof Button> & {
|
||||
rightContent?: ReactNode;
|
||||
alwaysShowRightContent?: boolean;
|
||||
textStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -105,6 +106,7 @@ export function TapField({
|
||||
children,
|
||||
className,
|
||||
rightContent,
|
||||
alwaysShowRightContent,
|
||||
textStyle,
|
||||
ref,
|
||||
...props
|
||||
@@ -135,7 +137,7 @@ export function TapField({
|
||||
{value}
|
||||
</Text>
|
||||
)}
|
||||
{!props.isDisabled && rightContent}
|
||||
{(!props.isDisabled || alwaysShowRightContent) && rightContent}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function MobileNavTabs() {
|
||||
// when cancel is true, it means that the user passed the upwards threshold
|
||||
// so we change the spring config to create a nice wobbly effect
|
||||
setNavbarState('open');
|
||||
api.start({
|
||||
void api.start({
|
||||
y: OPEN_FULL_Y,
|
||||
immediate: isTestEnv,
|
||||
config: canceled ? config.wobbly : config.stiff,
|
||||
@@ -71,7 +71,7 @@ export function MobileNavTabs() {
|
||||
const openDefault = useCallback(
|
||||
(velocity = 0) => {
|
||||
setNavbarState('default');
|
||||
api.start({
|
||||
void api.start({
|
||||
y: OPEN_DEFAULT_Y,
|
||||
immediate: isTestEnv,
|
||||
config: { ...config.stiff, velocity },
|
||||
@@ -83,7 +83,7 @@ export function MobileNavTabs() {
|
||||
const hide = useCallback(
|
||||
(velocity = 0) => {
|
||||
setNavbarState('hidden');
|
||||
api.start({
|
||||
void api.start({
|
||||
y: HIDDEN_Y,
|
||||
immediate: isTestEnv,
|
||||
config: { ...config.stiff, velocity },
|
||||
@@ -199,7 +199,7 @@ export function MobileNavTabs() {
|
||||
} else {
|
||||
// when the user keeps dragging, we just move the sheet according to
|
||||
// the cursor position
|
||||
api.start({ y: oy, immediate: true });
|
||||
void api.start({ y: oy, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -49,7 +49,7 @@ function TransactionListWithPreviews() {
|
||||
} = useTransactions({
|
||||
query: transactionsQuery,
|
||||
});
|
||||
const offBudgetAccounts = useOffBudgetAccounts();
|
||||
const { data: offBudgetAccounts = [] } = useOffBudgetAccounts();
|
||||
const offBudgetAccountsFilter = useCallback(
|
||||
(schedule: ScheduleEntity) =>
|
||||
offBudgetAccounts.some(a => a.id === schedule._account),
|
||||
|
||||
@@ -49,7 +49,7 @@ function TransactionListWithPreviews() {
|
||||
} = useTransactions({
|
||||
query: transactionsQuery,
|
||||
});
|
||||
const onBudgetAccounts = useOnBudgetAccounts();
|
||||
const { data: onBudgetAccounts = [] } = useOnBudgetAccounts();
|
||||
const onBudgetAccountsFilter = useCallback(
|
||||
(schedule: ScheduleEntity) =>
|
||||
onBudgetAccounts.some(a => a.id === schedule._account),
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useDeleteRuleMutation } from '@desktop-client/rules/mutations';
|
||||
|
||||
export function MobileRuleEditPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,6 +107,8 @@ export function MobileRuleEditPage() {
|
||||
void navigate(-1);
|
||||
};
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
const handleDelete = () => {
|
||||
// Runtime guard to ensure id exists
|
||||
if (!id || id === 'new') {
|
||||
@@ -120,23 +122,17 @@ export function MobileRuleEditPage() {
|
||||
options: {
|
||||
message: t('Are you sure you want to delete this rule?'),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await send('rule-delete', id);
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
void navigate('/rules');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
deleteRule(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
void navigate('/rules');
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { listen, send } from 'loot-core/platform/client/connection';
|
||||
import { listen } from 'loot-core/platform/client/connection';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -21,22 +21,24 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useDeleteRuleMutation } from '@desktop-client/rules';
|
||||
|
||||
export function MobileRulesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { showUndoNotification } = useUndo();
|
||||
const [visibleRulesParam] = useUrlParam('visible-rules');
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const {
|
||||
data: allRules = [],
|
||||
isLoading: isRulesLoading,
|
||||
refetch: refetchRules,
|
||||
} = useRules();
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
@@ -79,28 +81,10 @@ export function MobileRulesPage() {
|
||||
);
|
||||
}, [visibleRules, filter, filterData, schedules]);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await send('rules-get');
|
||||
const rules = result || [];
|
||||
setAllRules(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to load rules:', error);
|
||||
setAllRules([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
// Listen for undo events to refresh rules list
|
||||
useEffect(() => {
|
||||
const onUndo = () => {
|
||||
void loadRules();
|
||||
void refetchRules();
|
||||
};
|
||||
|
||||
const lastUndoEvent = undo.getUndoState('undoEvent');
|
||||
@@ -109,7 +93,7 @@ export function MobileRulesPage() {
|
||||
}
|
||||
|
||||
return listen('undo-event', onUndo);
|
||||
}, [loadRules]);
|
||||
}, [refetchRules]);
|
||||
|
||||
const handleRulePress = useCallback(
|
||||
(rule: RuleEntity) => {
|
||||
@@ -125,45 +109,22 @@ export function MobileRulesPage() {
|
||||
[setFilter],
|
||||
);
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
const handleRuleDelete = useCallback(
|
||||
async (rule: RuleEntity) => {
|
||||
try {
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
rule.id,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'warning',
|
||||
message: t(
|
||||
'This rule could not be deleted because it is linked to a schedule.',
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the rules list
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
(rule: RuleEntity) => {
|
||||
deleteRule(
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[dispatch, showUndoNotification, t, loadRules],
|
||||
[deleteRule, showUndoNotification, t],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -199,7 +160,7 @@ export function MobileRulesPage() {
|
||||
</View>
|
||||
<RulesList
|
||||
rules={filteredRules}
|
||||
isLoading={isLoading}
|
||||
isLoading={isRulesLoading}
|
||||
onRulePress={handleRulePress}
|
||||
onRuleDelete={handleRuleDelete}
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,11 @@ import { View } from '@actual-app/components/view';
|
||||
import { send, sendCatch } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type { RecurConfig, ScheduleEntity } from 'loot-core/types/models';
|
||||
import type {
|
||||
RecurConfig,
|
||||
RuleConditionEntity,
|
||||
ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button } from '@actual-app/components/button';
|
||||
import { SvgSplit } from '@actual-app/components/icons/v0';
|
||||
import {
|
||||
SvgAdd,
|
||||
SvgLocation,
|
||||
SvgPiggyBank,
|
||||
SvgTrash,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
@@ -31,6 +32,8 @@ import {
|
||||
} from 'date-fns';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import { DEFAULT_MAX_DISTANCE_METERS } from 'loot-core/shared/constants';
|
||||
import { calculateDistance } from 'loot-core/shared/location-utils';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import * as Platform from 'loot-core/shared/platform';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -72,14 +75,15 @@ import {
|
||||
} from '@desktop-client/components/mobile/MobileForms';
|
||||
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { createSingleTimeScheduleFromTransaction } from '@desktop-client/components/transactions/TransactionList';
|
||||
import { AmountInput } from '@desktop-client/components/util/AmountInput';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useDateFormat } from '@desktop-client/hooks/useDateFormat';
|
||||
import { useInitialMount } from '@desktop-client/hooks/useInitialMount';
|
||||
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
|
||||
import { useLocationPermission } from '@desktop-client/hooks/useLocationPermission';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useNearbyPayees } from '@desktop-client/hooks/useNearbyPayees';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import {
|
||||
SingleActiveEditFormProvider,
|
||||
@@ -88,8 +92,14 @@ import {
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useSavePayeeLocationMutation } from '@desktop-client/payees';
|
||||
import { locationService } from '@desktop-client/payees/location';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import {
|
||||
useCreateSingleTimeScheduleFromTransaction,
|
||||
useRunRulesMutation,
|
||||
} from '@desktop-client/rules';
|
||||
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
function getFieldName(transactionId: TransactionEntity['id'], field: string) {
|
||||
@@ -554,6 +564,10 @@ type TransactionEditInnerProps = {
|
||||
onDelete: (id: TransactionEntity['id']) => void;
|
||||
onSplit: (id: TransactionEntity['id']) => void;
|
||||
onAddSplit: (id: TransactionEntity['id']) => void;
|
||||
shouldShowSaveLocation?: boolean;
|
||||
onSaveLocation?: () => void;
|
||||
onSelectNearestPayee?: () => void;
|
||||
nearestPayee?: PayeeEntity | null;
|
||||
};
|
||||
|
||||
const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
@@ -569,6 +583,10 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
onDelete,
|
||||
onSplit,
|
||||
onAddSplit,
|
||||
shouldShowSaveLocation,
|
||||
onSaveLocation,
|
||||
onSelectNearestPayee,
|
||||
nearestPayee,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -671,6 +689,9 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
[categories, isBudgetTransfer, t],
|
||||
);
|
||||
|
||||
const { mutate: createSingleTimeScheduleFromTransaction } =
|
||||
useCreateSingleTimeScheduleFromTransaction();
|
||||
|
||||
const onSaveInner = useCallback(() => {
|
||||
const [unserializedTransaction] = unserializedTransactions;
|
||||
|
||||
@@ -729,19 +750,24 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
}
|
||||
: unserializedTransaction;
|
||||
|
||||
await createSingleTimeScheduleFromTransaction(
|
||||
transactionForSchedule,
|
||||
);
|
||||
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Schedule created successfully'),
|
||||
createSingleTimeScheduleFromTransaction(
|
||||
{
|
||||
transaction: transactionForSchedule,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Schedule created successfully'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
void navigate(-1);
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
void navigate(-1);
|
||||
},
|
||||
onCancel: onConfirmSave,
|
||||
},
|
||||
@@ -778,6 +804,7 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
unserializedTransactions,
|
||||
upcomingLength,
|
||||
t,
|
||||
createSingleTimeScheduleFromTransaction,
|
||||
]);
|
||||
|
||||
const onUpdateInner = useCallback(
|
||||
@@ -1090,6 +1117,56 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
}
|
||||
onPress={() => onEditFieldInner(transaction.id, 'payee')}
|
||||
data-testid="payee-field"
|
||||
alwaysShowRightContent={
|
||||
!!nearestPayee && !transaction.payee && !shouldShowSaveLocation
|
||||
}
|
||||
rightContent={
|
||||
shouldShowSaveLocation ? (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onSaveLocation}
|
||||
style={{
|
||||
backgroundColor: theme.buttonNormalBackground,
|
||||
border: `1px solid ${theme.buttonNormalBorder}`,
|
||||
color: theme.buttonNormalText,
|
||||
fontSize: '11px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 3,
|
||||
height: 'auto',
|
||||
minHeight: 'auto',
|
||||
}}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
<SvgLocation
|
||||
width={10}
|
||||
height={10}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Button>
|
||||
) : nearestPayee && !transaction.payee ? (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onSelectNearestPayee}
|
||||
style={{
|
||||
backgroundColor: theme.buttonNormalBackground,
|
||||
border: `1px solid ${theme.buttonNormalBorder}`,
|
||||
color: theme.buttonNormalText,
|
||||
fontSize: '11px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 3,
|
||||
height: 'auto',
|
||||
minHeight: 'auto',
|
||||
}}
|
||||
>
|
||||
<Trans>Nearby</Trans>
|
||||
<SvgLocation
|
||||
width={10}
|
||||
height={10}
|
||||
style={{ marginLeft: 4 }}
|
||||
/>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -1312,6 +1389,7 @@ function TransactionEditUnconnected({
|
||||
const { state: locationState } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const dispatch = useDispatch();
|
||||
const updatePayeeLocationMutation = useSavePayeeLocationMutation();
|
||||
const navigate = useNavigate();
|
||||
const [transactions, setTransactions] = useState<TransactionEntity[]>([]);
|
||||
const [fetchedTransactions, setFetchedTransactions] = useState<
|
||||
@@ -1333,6 +1411,11 @@ function TransactionEditUnconnected({
|
||||
[payees, searchParams],
|
||||
);
|
||||
|
||||
const locationAccess = useLocationPermission();
|
||||
const [shouldShowSaveLocation, setShouldShowSaveLocation] = useState(false);
|
||||
const { data: nearbyPayees = [] } = useNearbyPayees();
|
||||
const nearestPayee = nearbyPayees[0]?.payee ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
let unmounted = false;
|
||||
|
||||
@@ -1370,6 +1453,12 @@ function TransactionEditUnconnected({
|
||||
};
|
||||
}, [transactionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locationAccess) {
|
||||
setShouldShowSaveLocation(false);
|
||||
}
|
||||
}, [locationAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding.current) {
|
||||
setTransactions([
|
||||
@@ -1407,6 +1496,8 @@ function TransactionEditUnconnected({
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
const onUpdate = useCallback(
|
||||
async (
|
||||
serializedTransaction: TransactionEntity,
|
||||
@@ -1422,19 +1513,21 @@ function TransactionEditUnconnected({
|
||||
// this on new transactions because that's how desktop works.
|
||||
const newTransaction = { ...transaction };
|
||||
if (isTemporary(newTransaction)) {
|
||||
const afterRules = await send('rules-run', {
|
||||
transaction: newTransaction,
|
||||
});
|
||||
const afterRules = await runRulesAsync({ transaction: newTransaction });
|
||||
const diff = getChangedValues(newTransaction, afterRules);
|
||||
|
||||
if (diff) {
|
||||
Object.keys(diff).forEach(key => {
|
||||
const field = key as keyof TransactionEntity;
|
||||
// Update "empty" fields in general
|
||||
// Or update all fields if the payee changes (assists location-based entry by
|
||||
// applying rules to prefill category, notes, etc. based on the selected payee)
|
||||
if (
|
||||
newTransaction[field] == null ||
|
||||
newTransaction[field] === '' ||
|
||||
newTransaction[field] === 0 ||
|
||||
newTransaction[field] === false
|
||||
newTransaction[field] === false ||
|
||||
updatedField === 'payee'
|
||||
) {
|
||||
(newTransaction as Record<string, unknown>)[field] = diff[field];
|
||||
}
|
||||
@@ -1463,8 +1556,33 @@ function TransactionEditUnconnected({
|
||||
newTransaction,
|
||||
);
|
||||
setTransactions(newTransactions);
|
||||
|
||||
if (updatedField === 'payee') {
|
||||
setShouldShowSaveLocation(false);
|
||||
|
||||
if (newTransaction.payee && locationAccess) {
|
||||
const payeeLocations = await locationService.getPayeeLocations(
|
||||
newTransaction.payee,
|
||||
);
|
||||
if (payeeLocations.length === 0) {
|
||||
setShouldShowSaveLocation(true);
|
||||
} else {
|
||||
const currentPosition = await locationService.getCurrentPosition();
|
||||
const hasNearby = payeeLocations.some(
|
||||
loc =>
|
||||
calculateDistance(currentPosition, {
|
||||
latitude: loc.latitude,
|
||||
longitude: loc.longitude,
|
||||
}) <= DEFAULT_MAX_DISTANCE_METERS,
|
||||
);
|
||||
if (!hasNearby) {
|
||||
setShouldShowSaveLocation(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[dateFormat, transactions],
|
||||
[dateFormat, transactions, locationAccess, runRulesAsync],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
@@ -1544,6 +1662,39 @@ function TransactionEditUnconnected({
|
||||
[transactions],
|
||||
);
|
||||
|
||||
const onSaveLocation = useCallback(async () => {
|
||||
try {
|
||||
const [transaction] = transactions;
|
||||
if (transaction.payee) {
|
||||
await updatePayeeLocationMutation.mutateAsync(transaction.payee);
|
||||
setShouldShowSaveLocation(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save location', { error });
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to save location'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [t, transactions, dispatch, updatePayeeLocationMutation]);
|
||||
|
||||
const onSelectNearestPayee = useCallback(() => {
|
||||
const transaction = transactions[0];
|
||||
if (!nearestPayee || !transaction || transaction.payee) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
...serializeTransaction(transaction, dateFormat),
|
||||
payee: nearestPayee.id,
|
||||
};
|
||||
void onUpdate(updated, 'payee');
|
||||
}, [transactions, nearestPayee, onUpdate, dateFormat]);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<Page
|
||||
@@ -1669,6 +1820,10 @@ function TransactionEditUnconnected({
|
||||
onDelete={onDelete}
|
||||
onSplit={onSplit}
|
||||
onAddSplit={onAddSplit}
|
||||
shouldShowSaveLocation={shouldShowSaveLocation}
|
||||
onSaveLocation={onSaveLocation}
|
||||
onSelectNearestPayee={onSelectNearestPayee}
|
||||
nearestPayee={locationAccess ? nearestPayee : null}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -506,7 +506,7 @@ function SelectedTransactionsFloatingActionBar({
|
||||
: integerToCurrency(Number(value));
|
||||
break;
|
||||
case 'notes':
|
||||
displayValue = `${mode} with ${value}`;
|
||||
displayValue = `${mode} with ${String(value)}`;
|
||||
break;
|
||||
default:
|
||||
displayValue = value;
|
||||
@@ -514,15 +514,15 @@ function SelectedTransactionsFloatingActionBar({
|
||||
}
|
||||
|
||||
showUndoNotification({
|
||||
message: `Successfully updated ${name} of ${ids.length} transaction${ids.length > 1 ? 's' : ''} to [${displayValue}](#${displayValue}).`,
|
||||
message: `Successfully updated ${name} of ${ids.length} transaction${ids.length > 1 ? 's' : ''} to [${String(displayValue)}](#${String(displayValue)}).`,
|
||||
messageActions: {
|
||||
[String(displayValue)]: () => {
|
||||
switch (name) {
|
||||
case 'account':
|
||||
void navigate(`/accounts/${value}`);
|
||||
void navigate(`/accounts/${String(value)}`);
|
||||
break;
|
||||
case 'category':
|
||||
void navigate(`/categories/${value}`);
|
||||
void navigate(`/categories/${String(value)}`);
|
||||
break;
|
||||
case 'payee':
|
||||
void navigate(`/payees`);
|
||||
|
||||
@@ -34,10 +34,7 @@ import type { AccountEntity, TransactionEntity } from 'loot-core/types/models';
|
||||
|
||||
import { lookupName, Status } from './TransactionEdit';
|
||||
|
||||
import {
|
||||
makeAmountFullStyle,
|
||||
makeBalanceAmountStyle,
|
||||
} from '@desktop-client/components/budget/util';
|
||||
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
|
||||
import { useAccount } from '@desktop-client/hooks/useAccount';
|
||||
import { useCachedSchedules } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
@@ -283,7 +280,11 @@ export function TransactionListItem({
|
||||
<Text
|
||||
style={{
|
||||
...styles.tnum,
|
||||
...makeAmountFullStyle(amount),
|
||||
...makeAmountFullStyle(amount, {
|
||||
positiveColor: theme.tableText,
|
||||
negativeColor: theme.tableText,
|
||||
zeroColor: theme.numberNeutral,
|
||||
}),
|
||||
...textStyle,
|
||||
}}
|
||||
>
|
||||
@@ -295,7 +296,11 @@ export function TransactionListItem({
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
...styles.tnum,
|
||||
...makeBalanceAmountStyle(runningBalance),
|
||||
...makeAmountFullStyle(runningBalance, {
|
||||
positiveColor: theme.numberPositive,
|
||||
negativeColor: theme.numberNegative,
|
||||
zeroColor: theme.numberNeutral,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{integerToCurrency(runningBalance)}
|
||||
|
||||