Compare commits
41 Commits
matiss/sep
...
matiss/oxl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bed293f5fb | ||
|
|
1216a2e3f7 | ||
|
|
6445180974 | ||
|
|
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 | ||
|
|
f9e09ca59b | ||
|
|
8081b8829e |
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));
|
||||
|
||||
2
.github/actions/docs-spelling/expect.txt
vendored
@@ -31,6 +31,7 @@ CAGLPTPL
|
||||
Caixa
|
||||
CAMT
|
||||
cashflow
|
||||
Catppuccin
|
||||
Cetelem
|
||||
cimode
|
||||
Citi
|
||||
@@ -109,6 +110,7 @@ KBCBE
|
||||
Keycloak
|
||||
Khurozov
|
||||
KORT
|
||||
KRW
|
||||
Kreditbank
|
||||
lage
|
||||
LHV
|
||||
|
||||
3
.github/actions/docs-spelling/patterns.txt
vendored
@@ -79,3 +79,6 @@
|
||||
|
||||
# allowlist specific non-English words with non-ASCII characters
|
||||
\b(Länsförsäkringar|München|Złoty)\b
|
||||
|
||||
# allowlist specific proper nouns
|
||||
\b(CodeRabbit)\b
|
||||
|
||||
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
|
||||
|
||||
7
.github/workflows/merge-freeze-unfreeze.yml
vendored
@@ -1,18 +1,21 @@
|
||||
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
|
||||
# so it can be merged during a freeze. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
|
||||
# so it can be merged during a freeze. Uses pull_request_target so the workflow runs in
|
||||
# the base repo and has access to MERGEFREEZE_ACCESS_TOKEN for fork PRs; it does not
|
||||
# checkout or run any PR code. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
|
||||
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
|
||||
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
|
||||
|
||||
name: Merge Freeze – add PR to unblocked list
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
unfreeze:
|
||||
if: ${{ github.event.label.name == 'unfreeze' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
concurrency:
|
||||
group: merge-freeze-unfreeze-${{ github.ref }}-labels
|
||||
cancel-in-progress: false
|
||||
|
||||
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'
|
||||
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
|
||||
|
||||
@@ -102,7 +102,11 @@
|
||||
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
|
||||
"typescript/no-duplicate-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": "warn", // TODO: covert to error
|
||||
"typescript/restrict-template-expressions": "warn", // TODO: covert to error
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "error",
|
||||
|
||||
43
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
# Emit loot-core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
yarn workspace loot-core exec tsc -p tsconfig.json
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
(
|
||||
|
||||
@@ -54,10 +54,10 @@
|
||||
"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",
|
||||
"lint": "yarn workspace @actual-app/api clean && oxfmt --check . && oxlint --type-aware",
|
||||
"lint:fix": "yarn workspace @actual-app/api clean && oxfmt . && oxlint --fix --type-aware",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"typecheck": "yarn workspace @actual-app/api clean && tsc -b && tsc -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
APITagEntity,
|
||||
} from 'loot-core/server/api-models';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import type { ImportTransactionsOpts } from 'loot-core/types/api-handlers';
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
@@ -126,11 +127,6 @@ export function addTransactions(
|
||||
});
|
||||
}
|
||||
|
||||
export type ImportTransactionsOpts = {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export function importTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
transactions: ImportTransactionEntity[],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.2.1",
|
||||
"version": "26.3.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
|
||||
@@ -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,14 +9,18 @@
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
// TEMPORARY
|
||||
"loot-core/*": ["../loot-core/src/*"]
|
||||
},
|
||||
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -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": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"extensionless": "^2.0.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
"ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/ci-actions/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*", "bin/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.7",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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: 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 |
|
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 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "26.2.1",
|
||||
"version": "26.3.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
@@ -97,7 +97,6 @@
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.18",
|
||||
"xml2js": "^0.6.2"
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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']>> =
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -518,7 +518,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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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';
|
||||
@@ -79,7 +82,9 @@ 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,6 +93,8 @@ 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 { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
|
||||
@@ -554,6 +561,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 +580,10 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
onDelete,
|
||||
onSplit,
|
||||
onAddSplit,
|
||||
shouldShowSaveLocation,
|
||||
onSaveLocation,
|
||||
onSelectNearestPayee,
|
||||
nearestPayee,
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -1090,6 +1105,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 +1377,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 +1399,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 +1441,12 @@ function TransactionEditUnconnected({
|
||||
};
|
||||
}, [transactionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!locationAccess) {
|
||||
setShouldShowSaveLocation(false);
|
||||
}
|
||||
}, [locationAccess]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdding.current) {
|
||||
setTransactions([
|
||||
@@ -1430,11 +1507,15 @@ function TransactionEditUnconnected({
|
||||
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 +1544,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],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
@@ -1544,6 +1650,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 +1808,10 @@ function TransactionEditUnconnected({
|
||||
onDelete={onDelete}
|
||||
onSplit={onSplit}
|
||||
onAddSplit={onAddSplit}
|
||||
shouldShowSaveLocation={shouldShowSaveLocation}
|
||||
onSaveLocation={onSaveLocation}
|
||||
onSelectNearestPayee={onSelectNearestPayee}
|
||||
nearestPayee={locationAccess ? nearestPayee : null}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function AccountAutocompleteModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
{isNarrowWidth && (
|
||||
<ModalHeader
|
||||
@@ -58,7 +58,7 @@ export function AccountAutocompleteModal({
|
||||
}
|
||||
rightContent={
|
||||
<ModalCloseButton
|
||||
onPress={close}
|
||||
onPress={() => state.close()}
|
||||
style={{ color: theme.menuAutoCompleteText }}
|
||||
/>
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export function AccountAutocompleteModal({
|
||||
focused
|
||||
embedded
|
||||
closeOnBlur={false}
|
||||
onClose={close}
|
||||
onClose={() => state.close()}
|
||||
{...defaultAutocompleteProps}
|
||||
onSelect={onSelect}
|
||||
includeClosedAccounts={includeClosedAccounts}
|
||||
|
||||
@@ -114,7 +114,7 @@ export function AccountMenuModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
leftContent={
|
||||
@@ -140,7 +140,7 @@ export function AccountMenuModal({
|
||||
)}
|
||||
</Fragment>
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Template } from 'loot-core/types/models/templates';
|
||||
|
||||
import { migrateTemplatesToAutomations } from './BudgetAutomationsModal';
|
||||
|
||||
describe('migrateTemplatesToAutomations', () => {
|
||||
it('preserves simple templates that have no limit and no monthly amount', () => {
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 5,
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].displayType).toBe('week');
|
||||
expect(result[0].template).toEqual(simpleTemplate);
|
||||
expect(result[0].id).toMatch(/^automation-/);
|
||||
});
|
||||
|
||||
it('expands a simple template with limit into limit and refill entries', () => {
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 7,
|
||||
limit: {
|
||||
amount: 120,
|
||||
hold: true,
|
||||
period: 'monthly',
|
||||
start: '2026-02-01',
|
||||
},
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].displayType).toBe('limit');
|
||||
expect(result[0].template).toEqual({
|
||||
type: 'limit',
|
||||
amount: 120,
|
||||
hold: true,
|
||||
period: 'monthly',
|
||||
start: '2026-02-01',
|
||||
directive: 'template',
|
||||
priority: null,
|
||||
});
|
||||
expect(result[1].displayType).toBe('refill');
|
||||
expect(result[1].template).toEqual({
|
||||
type: 'refill',
|
||||
directive: 'template',
|
||||
priority: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a simple template with monthly amount into one periodic entry', () => {
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 3,
|
||||
monthly: 45,
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].displayType).toBe('week');
|
||||
expect(result[0].template).toMatchObject({
|
||||
type: 'periodic',
|
||||
amount: 45,
|
||||
period: {
|
||||
period: 'month',
|
||||
amount: 1,
|
||||
},
|
||||
directive: 'template',
|
||||
priority: 3,
|
||||
});
|
||||
expect(result[0].template).toMatchObject({
|
||||
starting: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('expands a simple template with both limit and monthly into three entries in order', () => {
|
||||
const simpleTemplate = {
|
||||
type: 'simple',
|
||||
directive: 'template',
|
||||
priority: 11,
|
||||
monthly: 20,
|
||||
limit: {
|
||||
amount: 200,
|
||||
hold: false,
|
||||
period: 'weekly',
|
||||
},
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([simpleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(entry => entry.displayType)).toEqual([
|
||||
'limit',
|
||||
'refill',
|
||||
'week',
|
||||
]);
|
||||
expect(result[2].template).toMatchObject({
|
||||
type: 'periodic',
|
||||
amount: 20,
|
||||
directive: 'template',
|
||||
priority: 11,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a single entry for non-simple templates', () => {
|
||||
const scheduleTemplate = {
|
||||
type: 'schedule',
|
||||
directive: 'template',
|
||||
priority: 1,
|
||||
name: 'rent',
|
||||
} satisfies Template;
|
||||
|
||||
const result = migrateTemplatesToAutomations([scheduleTemplate]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].displayType).toBe('schedule');
|
||||
expect(result[0].template).toEqual(scheduleTemplate);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { View } from '@actual-app/components/view';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import { dayFromDate, firstDayOfMonth } from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type {
|
||||
CategoryGroupEntity,
|
||||
@@ -19,6 +20,7 @@ import type { Template } from 'loot-core/types/models/templates';
|
||||
|
||||
import { Warning } from '@desktop-client/components/alerts';
|
||||
import { BudgetAutomation } from '@desktop-client/components/budget/goals/BudgetAutomation';
|
||||
import type { DisplayTemplateType } from '@desktop-client/components/budget/goals/constants';
|
||||
import { DEFAULT_PRIORITY } from '@desktop-client/components/budget/goals/reducer';
|
||||
import { useBudgetAutomationCategories } from '@desktop-client/components/budget/goals/useBudgetAutomationCategories';
|
||||
import { Link } from '@desktop-client/components/common/Link';
|
||||
@@ -34,6 +36,120 @@ import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
type AutomationEntry = {
|
||||
id: string;
|
||||
template: Template;
|
||||
displayType: DisplayTemplateType;
|
||||
};
|
||||
|
||||
function getDisplayTypeFromTemplate(template: Template): DisplayTemplateType {
|
||||
switch (template.type) {
|
||||
case 'percentage':
|
||||
return 'percentage';
|
||||
case 'schedule':
|
||||
return 'schedule';
|
||||
case 'periodic':
|
||||
case 'simple':
|
||||
return 'week';
|
||||
case 'limit':
|
||||
return 'limit';
|
||||
case 'refill':
|
||||
return 'refill';
|
||||
case 'average':
|
||||
case 'copy':
|
||||
return 'historical';
|
||||
default:
|
||||
return 'week';
|
||||
}
|
||||
}
|
||||
|
||||
function createAutomationEntry(
|
||||
template: Template,
|
||||
displayType: DisplayTemplateType,
|
||||
): AutomationEntry {
|
||||
return {
|
||||
id: uniqueId('automation-'),
|
||||
template,
|
||||
displayType,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateTemplatesToAutomations(
|
||||
templates: Template[],
|
||||
): AutomationEntry[] {
|
||||
const entries: AutomationEntry[] = [];
|
||||
|
||||
templates.forEach(template => {
|
||||
// Expand simple templates into limit, refill, and/or periodic templates
|
||||
if (template.type === 'simple') {
|
||||
let hasExpandedTemplate = false;
|
||||
|
||||
if (template.limit) {
|
||||
hasExpandedTemplate = true;
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'limit',
|
||||
amount: template.limit.amount,
|
||||
hold: template.limit.hold,
|
||||
period: template.limit.period,
|
||||
start: template.limit.start,
|
||||
directive: 'template',
|
||||
priority: null,
|
||||
},
|
||||
'limit',
|
||||
),
|
||||
);
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'refill',
|
||||
directive: 'template',
|
||||
priority: template.priority,
|
||||
},
|
||||
'refill',
|
||||
),
|
||||
);
|
||||
}
|
||||
// If it has a monthly amount, create a periodic template
|
||||
if (template.monthly != null && template.monthly !== 0) {
|
||||
hasExpandedTemplate = true;
|
||||
entries.push(
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: template.monthly,
|
||||
period: {
|
||||
period: 'month',
|
||||
amount: 1,
|
||||
},
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
directive: 'template',
|
||||
priority: template.priority,
|
||||
},
|
||||
'week',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasExpandedTemplate) {
|
||||
entries.push(
|
||||
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other template types, create a single entry
|
||||
entries.push(
|
||||
createAutomationEntry(template, getDisplayTypeFromTemplate(template)),
|
||||
);
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function BudgetAutomationList({
|
||||
automations,
|
||||
setAutomations,
|
||||
@@ -41,49 +157,69 @@ function BudgetAutomationList({
|
||||
categories,
|
||||
style,
|
||||
}: {
|
||||
automations: Template[];
|
||||
setAutomations: (fn: (prev: Template[]) => Template[]) => void;
|
||||
automations: AutomationEntry[];
|
||||
setAutomations: (fn: (prev: AutomationEntry[]) => AutomationEntry[]) => void;
|
||||
schedules: readonly ScheduleEntity[];
|
||||
categories: CategoryGroupEntity[];
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const [automationIds, setAutomationIds] = useState(() => {
|
||||
// automations don't have ids, so we need to generate them
|
||||
return automations.map(() => uniqueId('automation-'));
|
||||
});
|
||||
|
||||
const onAdd = () => {
|
||||
const newId = uniqueId('automation-');
|
||||
setAutomationIds(prevIds => [...prevIds, newId]);
|
||||
setAutomations(prev => [
|
||||
...prev,
|
||||
{
|
||||
type: 'simple',
|
||||
monthly: 5,
|
||||
directive: 'template',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
createAutomationEntry(
|
||||
{
|
||||
type: 'periodic',
|
||||
amount: 500,
|
||||
period: {
|
||||
period: 'month',
|
||||
amount: 1,
|
||||
},
|
||||
starting: dayFromDate(firstDayOfMonth(new Date())),
|
||||
directive: 'template',
|
||||
priority: DEFAULT_PRIORITY,
|
||||
},
|
||||
'week',
|
||||
),
|
||||
]);
|
||||
};
|
||||
const onAddLimit = () => {
|
||||
setAutomations(prev => [
|
||||
...prev,
|
||||
createAutomationEntry(
|
||||
{
|
||||
directive: 'template',
|
||||
type: 'limit',
|
||||
amount: 500,
|
||||
period: 'monthly',
|
||||
hold: false,
|
||||
priority: null,
|
||||
},
|
||||
'limit',
|
||||
),
|
||||
]);
|
||||
};
|
||||
const onDelete = (index: number) => () => {
|
||||
setAutomations(prev => [...prev.slice(0, index), ...prev.slice(index + 1)]);
|
||||
setAutomationIds(prev => [
|
||||
...prev.slice(0, index),
|
||||
...prev.slice(index + 1),
|
||||
]);
|
||||
};
|
||||
|
||||
const onSave = useCallback(
|
||||
(index: number) => (template: Template) => {
|
||||
setAutomations(prev =>
|
||||
prev.map((oldAutomation, mapIndex) =>
|
||||
mapIndex === index ? template : oldAutomation,
|
||||
),
|
||||
);
|
||||
},
|
||||
(index: number) =>
|
||||
(template: Template, displayType: DisplayTemplateType) => {
|
||||
setAutomations(prev =>
|
||||
prev.map((oldAutomation, mapIndex) =>
|
||||
mapIndex === index
|
||||
? { ...oldAutomation, template, displayType }
|
||||
: oldAutomation,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setAutomations],
|
||||
);
|
||||
|
||||
const hasLimitAutomation = automations.some(
|
||||
automation => automation.displayType === 'limit',
|
||||
);
|
||||
|
||||
return (
|
||||
<SpaceBetween
|
||||
direction="vertical"
|
||||
@@ -97,12 +233,16 @@ function BudgetAutomationList({
|
||||
>
|
||||
{automations.map((automation, index) => (
|
||||
<BudgetAutomation
|
||||
key={automationIds[index]}
|
||||
key={automation.id}
|
||||
onSave={onSave(index)}
|
||||
onDelete={onDelete(index)}
|
||||
template={automation}
|
||||
template={automation.template}
|
||||
categories={categories}
|
||||
schedules={schedules}
|
||||
hasLimitAutomation={hasLimitAutomation}
|
||||
onAddLimitAutomation={
|
||||
automation.displayType === 'refill' ? onAddLimit : undefined
|
||||
}
|
||||
readOnlyStyle={{
|
||||
color: theme.pillText,
|
||||
backgroundColor: theme.pillBackground,
|
||||
@@ -181,12 +321,20 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [automations, setAutomations] = useState<Record<string, Template[]>>(
|
||||
{},
|
||||
);
|
||||
const [automations, setAutomations] = useState<
|
||||
Record<string, AutomationEntry[]>
|
||||
>({});
|
||||
const onLoaded = useCallback((result: Record<string, Template[]>) => {
|
||||
const next: Record<string, AutomationEntry[]> = {};
|
||||
for (const [id, templates] of Object.entries(result)) {
|
||||
next[id] = migrateTemplatesToAutomations(templates);
|
||||
}
|
||||
setAutomations(next);
|
||||
}, []);
|
||||
|
||||
const { loading } = useBudgetAutomations({
|
||||
categoryId,
|
||||
onLoaded: setAutomations,
|
||||
onLoaded,
|
||||
});
|
||||
|
||||
const schedulesQuery = useMemo(() => q('schedules').select('*'), []);
|
||||
@@ -205,11 +353,12 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const templates = automations[categoryId].map(({ template }) => template);
|
||||
await send('budget/set-category-automations', {
|
||||
categoriesWithTemplates: [
|
||||
{
|
||||
id: categoryId,
|
||||
templates: automations[categoryId],
|
||||
templates,
|
||||
},
|
||||
],
|
||||
source: 'ui',
|
||||
@@ -224,7 +373,7 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
style: { width: 850, height: 650, paddingBottom: 20 },
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<SpaceBetween
|
||||
direction="vertical"
|
||||
wrap={false}
|
||||
@@ -235,7 +384,7 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
title={t('Budget automations: {{category}}', {
|
||||
category: currentCategory?.name,
|
||||
})}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
{loading ? (
|
||||
<View
|
||||
@@ -252,13 +401,18 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
<AnimatedLoading style={{ width: 20, height: 20 }} />
|
||||
</View>
|
||||
) : (
|
||||
<SpaceBetween align="stretch" direction="vertical">
|
||||
<SpaceBetween align="stretch" direction="vertical" wrap={false}>
|
||||
{needsMigration && (
|
||||
<BudgetAutomationMigrationWarning categoryId={categoryId} />
|
||||
<BudgetAutomationMigrationWarning
|
||||
categoryId={categoryId}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<BudgetAutomationList
|
||||
automations={automations[categoryId] || []}
|
||||
setAutomations={(cb: (prev: Template[]) => Template[]) => {
|
||||
setAutomations={(
|
||||
cb: (prev: AutomationEntry[]) => AutomationEntry[],
|
||||
) => {
|
||||
setAutomations(prev => ({
|
||||
...prev,
|
||||
[categoryId]: cb(prev[categoryId] || []),
|
||||
@@ -286,7 +440,10 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-automations-unmigrate',
|
||||
options: { categoryId, templates },
|
||||
options: {
|
||||
categoryId,
|
||||
templates: templates.map(({ template }) => template),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -296,10 +453,13 @@ export function BudgetAutomationsModal({ categoryId }: { categoryId: string }) {
|
||||
</Link>
|
||||
)}
|
||||
{/* <View style={{ flex: 1 }} /> */}
|
||||
<Button onPress={close}>
|
||||
<Button onPress={() => state.close()}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button variant="primary" onPress={() => onSave(close)}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => onSave(() => state.close())}
|
||||
>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
</SpaceBetween>
|
||||
|
||||
@@ -33,11 +33,11 @@ export function BudgetPageMenuModal({
|
||||
|
||||
return (
|
||||
<Modal name="budget-page-menu">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
showLogo
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<BudgetPageMenu
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CategoryAutocompleteModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
{isNarrowWidth && (
|
||||
<ModalHeader
|
||||
@@ -66,7 +66,7 @@ export function CategoryAutocompleteModal({
|
||||
}
|
||||
rightContent={
|
||||
<ModalCloseButton
|
||||
onPress={close}
|
||||
onPress={() => state.close()}
|
||||
style={{ color: theme.menuAutoCompleteText }}
|
||||
/>
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function CategoryAutocompleteModal({
|
||||
closeOnSelect={closeOnSelect}
|
||||
clearOnSelect={clearOnSelect}
|
||||
showSplitOption={false}
|
||||
onClose={close}
|
||||
onClose={() => state.close()}
|
||||
{...defaultAutocompleteProps}
|
||||
onSelect={onSelect}
|
||||
categoryGroups={categoryGroups}
|
||||
|
||||
@@ -54,7 +54,7 @@ export function CategoryGroupAutocompleteModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
{isNarrowWidth && (
|
||||
<ModalHeader
|
||||
@@ -66,7 +66,7 @@ export function CategoryGroupAutocompleteModal({
|
||||
}
|
||||
rightContent={
|
||||
<ModalCloseButton
|
||||
onPress={close}
|
||||
onPress={() => state.close()}
|
||||
style={{ color: theme.menuAutoCompleteText }}
|
||||
/>
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function CategoryGroupAutocompleteModal({
|
||||
closeOnBlur={false}
|
||||
closeOnSelect={closeOnSelect}
|
||||
clearOnSelect={clearOnSelect}
|
||||
onClose={close}
|
||||
onClose={() => state.close()}
|
||||
{...defaultAutocompleteProps}
|
||||
onSelect={onSelect}
|
||||
categoryGroups={categoryGroups}
|
||||
|
||||
@@ -133,7 +133,7 @@ export function CategoryGroupMenuModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
leftContent={
|
||||
@@ -150,7 +150,7 @@ export function CategoryGroupMenuModal({
|
||||
onTitleUpdate={onRename}
|
||||
/>
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -237,7 +237,7 @@ export function CategoryGroupMenuModal({
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
onApplyBudgetTemplatesInGroup={() => {
|
||||
_onApplyBudgetTemplatesInGroup();
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: t('budget templates have been applied.'),
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ export function CategoryMenuModal({
|
||||
style: { height: '45vh' },
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
leftContent={
|
||||
@@ -104,7 +104,7 @@ export function CategoryMenuModal({
|
||||
onTitleUpdate={onRename}
|
||||
/>
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -130,11 +130,11 @@ export function CloseAccountModal({
|
||||
isLoading={loading}
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Close Account')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<Paragraph>
|
||||
@@ -164,7 +164,7 @@ export function CloseAccountModal({
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
if (onSubmit(e)) {
|
||||
close();
|
||||
state.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -286,7 +286,7 @@ export function CloseAccountModal({
|
||||
id: account.id,
|
||||
forced: true,
|
||||
});
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
style={{ color: theme.errorText }}
|
||||
>
|
||||
@@ -311,7 +311,7 @@ export function CloseAccountModal({
|
||||
marginRight: 10,
|
||||
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
|
||||
}}
|
||||
onPress={close}
|
||||
onPress={() => state.close()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -71,11 +71,11 @@ export function ConfirmCategoryDeleteModal({
|
||||
name="confirm-category-delete"
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Confirm Delete')} // Use translation for title
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{group ? (
|
||||
@@ -177,7 +177,7 @@ export function ConfirmCategoryDeleteModal({
|
||||
setError('required-transfer');
|
||||
} else {
|
||||
onDelete(transferCategory);
|
||||
close();
|
||||
state.close();
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -34,11 +34,11 @@ export function ConfirmDeleteModal({
|
||||
|
||||
return (
|
||||
<Modal name="confirm-delete">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Confirm Delete')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>{message}</Paragraph>
|
||||
@@ -53,7 +53,7 @@ export function ConfirmDeleteModal({
|
||||
marginRight: 10,
|
||||
...narrowButtonStyle,
|
||||
}}
|
||||
onPress={close}
|
||||
onPress={() => state.close()}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
@@ -63,7 +63,7 @@ export function ConfirmDeleteModal({
|
||||
style={narrowButtonStyle}
|
||||
onPress={() => {
|
||||
onConfirm();
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
|
||||
@@ -40,11 +40,11 @@ export function ConfirmTransactionEditModal({
|
||||
name="confirm-transaction-edit"
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Reconciled Transaction')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{confirmReason === 'batchDeleteWithReconciled' ? (
|
||||
@@ -109,7 +109,7 @@ export function ConfirmTransactionEditModal({
|
||||
...narrowButtonStyle,
|
||||
}}
|
||||
onPress={() => {
|
||||
close();
|
||||
state.close();
|
||||
onCancel();
|
||||
}}
|
||||
>
|
||||
@@ -124,7 +124,7 @@ export function ConfirmTransactionEditModal({
|
||||
...narrowButtonStyle,
|
||||
}}
|
||||
onPress={() => {
|
||||
close();
|
||||
state.close();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -30,11 +30,11 @@ export function ConfirmUnlinkAccountModal({
|
||||
name="confirm-unlink-account"
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Confirm Unlink')} // Use translation for title
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>
|
||||
@@ -59,7 +59,7 @@ export function ConfirmUnlinkAccountModal({
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button style={{ marginRight: 10 }} onPress={close}>
|
||||
<Button style={{ marginRight: 10 }} onPress={() => state.close()}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<InitialFocus>
|
||||
@@ -67,7 +67,7 @@ export function ConfirmUnlinkAccountModal({
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
onUnlink();
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<Trans>Unlink</Trans>
|
||||
|
||||
@@ -42,11 +42,11 @@ export function ConvertToScheduleModal({
|
||||
name="convert-to-schedule"
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Convert to Schedule')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Block>
|
||||
@@ -95,7 +95,7 @@ export function ConvertToScheduleModal({
|
||||
...(isNarrowWidth && { flex: 1 }),
|
||||
}}
|
||||
onPress={() => {
|
||||
close();
|
||||
state.close();
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
@@ -110,7 +110,7 @@ export function ConvertToScheduleModal({
|
||||
...(isNarrowWidth && { flex: 1 }),
|
||||
}}
|
||||
onPress={() => {
|
||||
close();
|
||||
state.close();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -32,11 +32,11 @@ export function CopyWidgetToDashboardModal({
|
||||
|
||||
return (
|
||||
<Modal name="copy-widget-to-dashboard">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Copy to dashboard')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
@@ -45,7 +45,7 @@ export function CopyWidgetToDashboardModal({
|
||||
items={items}
|
||||
onMenuSelect={item => {
|
||||
onSelect(item);
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -61,7 +61,7 @@ export function CopyWidgetToDashboardModal({
|
||||
marginTop: 15,
|
||||
}}
|
||||
>
|
||||
<Button onPress={close}>
|
||||
<Button onPress={() => state.close()}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -98,11 +98,11 @@ export function CoverModal({
|
||||
|
||||
return (
|
||||
<Modal name="cover">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={title}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<FieldLabel title={t('Cover this amount:')} />
|
||||
@@ -148,7 +148,7 @@ export function CoverModal({
|
||||
}}
|
||||
onPress={() => {
|
||||
_onSubmit();
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
|
||||
@@ -335,11 +335,11 @@ export function CreateAccountModal({
|
||||
|
||||
return (
|
||||
<Modal name="add-account">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={title}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
|
||||
{upgradingAccountId == null && (
|
||||
|
||||
@@ -71,13 +71,13 @@ export function CreateEncryptionKeyModal({
|
||||
|
||||
return (
|
||||
<Modal name="create-encryption-key">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={
|
||||
isRecreating ? t('Generate new key') : t('Enable encryption')
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -174,7 +174,7 @@ export function CreateEncryptionKeyModal({
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void onCreateKey(close);
|
||||
void onCreateKey(() => state.close());
|
||||
}}
|
||||
>
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
|
||||
@@ -83,13 +83,13 @@ export function CreateLocalAccountModal() {
|
||||
};
|
||||
return (
|
||||
<Modal name="add-local-account">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={
|
||||
<ModalTitle title={t('Create Local Account')} shrinkOnOverflow />
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<Form onSubmit={onSubmit}>
|
||||
@@ -191,7 +191,7 @@ export function CreateLocalAccountModal() {
|
||||
)}
|
||||
|
||||
<ModalButtons>
|
||||
<Button onPress={close}>
|
||||
<Button onPress={() => state.close()}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -272,12 +272,12 @@ export function EditFieldModal({
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
{isNarrowWidth && (
|
||||
<ModalHeader
|
||||
title={label}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
)}
|
||||
<View>
|
||||
@@ -291,7 +291,9 @@ export function EditFieldModal({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<View style={{ flex: 1 }}>{editor({ close })}</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
{editor({ close: () => state.close() })}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,19 +25,19 @@ export function EditRuleModal({
|
||||
|
||||
return (
|
||||
<Modal name="edit-rule">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Rule')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<RuleEditor
|
||||
rule={defaultRule}
|
||||
onSave={rule => {
|
||||
originalOnSave?.(rule);
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
onCancel={close}
|
||||
onCancel={() => state.close()}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: 900,
|
||||
|
||||
@@ -133,7 +133,7 @@ export function EditUserFinanceApp({
|
||||
const isExistingUser = 'id' in defaultUser && !!defaultUser.id;
|
||||
return (
|
||||
<Modal name="edit-user">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={
|
||||
@@ -143,14 +143,14 @@ export function EditUserFinanceApp({
|
||||
})
|
||||
: t('Add user')
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<EditUser
|
||||
defaultUser={defaultUser}
|
||||
onSave={async (method, user, setError) => {
|
||||
if (await saveUser(method, user, setError)) {
|
||||
originalOnSave(user);
|
||||
close();
|
||||
state.close();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -49,11 +49,11 @@ export function EnvelopeBalanceMenuModal({
|
||||
|
||||
return (
|
||||
<Modal name="envelope-balance-menu">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={<ModalTitle title={category.name} shrinkOnOverflow />}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -66,11 +66,11 @@ export function EnvelopeBudgetMenuModal({
|
||||
|
||||
return (
|
||||
<Modal name="envelope-budget-menu">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={<ModalTitle title={category.name} shrinkOnOverflow />}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -92,7 +92,7 @@ export function EnvelopeBudgetMenuModal({
|
||||
focused={amountFocused}
|
||||
onFocus={() => setAmountFocused(true)}
|
||||
onBlur={() => setAmountFocused(false)}
|
||||
onEnter={close}
|
||||
onEnter={() => state.close()}
|
||||
zeroSign="+"
|
||||
focusedStyle={{
|
||||
width: 'auto',
|
||||
|
||||
@@ -77,11 +77,11 @@ export function EnvelopeBudgetMonthMenuModal({
|
||||
style: { height: '50vh' },
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={displayMonth}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -168,7 +168,7 @@ export function EnvelopeBudgetMonthMenuModal({
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
onCopyLastMonthBudget={() => {
|
||||
onBudgetAction(month, 'copy-last');
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
"{{displayMonth}} budgets have all been set to last month's budgeted amounts.",
|
||||
@@ -178,7 +178,7 @@ export function EnvelopeBudgetMonthMenuModal({
|
||||
}}
|
||||
onSetBudgetsToZero={() => {
|
||||
onBudgetAction(month, 'set-zero');
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'{{displayMonth}} budgets have all been set to zero.',
|
||||
@@ -188,18 +188,18 @@ export function EnvelopeBudgetMonthMenuModal({
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
onBudgetAction(month, `set-${numberOfMonths}-avg`);
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: `${displayMonth} budgets have all been set to ${numberOfMonths === 12 ? 'yearly' : `${numberOfMonths} month`} average.`,
|
||||
});
|
||||
}}
|
||||
onCheckTemplates={() => {
|
||||
onBudgetAction(month, 'check-templates');
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
onApplyBudgetTemplates={() => {
|
||||
onBudgetAction(month, 'apply-goal-template');
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'{{displayMonth}} budget templates have been applied.',
|
||||
@@ -209,7 +209,7 @@ export function EnvelopeBudgetMonthMenuModal({
|
||||
}}
|
||||
onOverwriteWithBudgetTemplates={() => {
|
||||
onBudgetAction(month, 'overwrite-goal-template');
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'{{displayMonth}} budget templates have been overwritten.',
|
||||
@@ -219,7 +219,7 @@ export function EnvelopeBudgetMonthMenuModal({
|
||||
}}
|
||||
onEndOfMonthCleanup={() => {
|
||||
onBudgetAction(month, 'cleanup-goal-template');
|
||||
close();
|
||||
state.close();
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'{{displayMonth}} end-of-month cleanup templates have been applied.',
|
||||
|
||||
@@ -158,11 +158,11 @@ export function EnvelopeBudgetSummaryModal({
|
||||
|
||||
return (
|
||||
<Modal name="envelope-budget-summary">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Budget Summary')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<SheetNameProvider name={sheetForMonth(month)}>
|
||||
<TotalsList
|
||||
@@ -180,7 +180,7 @@ export function EnvelopeBudgetSummaryModal({
|
||||
amountStyle={{
|
||||
...styles.underlinedText,
|
||||
}}
|
||||
onClick={() => onClick({ close })}
|
||||
onClick={() => onClick({ close: () => state.close() })}
|
||||
isTotalsListTooltipDisabled
|
||||
/>
|
||||
</SheetNameProvider>
|
||||
|
||||
@@ -54,11 +54,11 @@ export function EnvelopeIncomeBalanceMenuModal({
|
||||
|
||||
return (
|
||||
<Modal name="envelope-income-balance-menu">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={<ModalTitle title={category.name} shrinkOnOverflow />}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -34,11 +34,11 @@ export function EnvelopeToBudgetMenuModal({
|
||||
|
||||
return (
|
||||
<Modal name="envelope-summary-to-budget-menu">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
showLogo
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<ToBudgetMenu
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function FixEncryptionKeyModal({
|
||||
|
||||
return (
|
||||
<Modal name="fix-encryption-key">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={
|
||||
@@ -72,7 +72,7 @@ export function FixEncryptionKeyModal({
|
||||
? t('Decrypt budget file')
|
||||
: t('This file is encrypted')
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -111,7 +111,7 @@ export function FixEncryptionKeyModal({
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
void onUpdateKey(close);
|
||||
void onUpdateKey(() => state.close());
|
||||
}}
|
||||
>
|
||||
<View
|
||||
@@ -164,7 +164,7 @@ export function FixEncryptionKeyModal({
|
||||
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
|
||||
marginRight: 10,
|
||||
}}
|
||||
onPress={close}
|
||||
onPress={() => state.close()}
|
||||
>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -273,11 +273,11 @@ export function GoCardlessExternalMsgModal({
|
||||
onClose={onClose}
|
||||
containerProps={{ style: { width: '30vw' } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Link Your Bank')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<Paragraph style={{ fontSize: 15 }}>
|
||||
|
||||
@@ -83,11 +83,11 @@ export const GoCardlessInitialiseModal = ({
|
||||
|
||||
return (
|
||||
<Modal name="gocardless-init" containerProps={{ style: { width: '30vw' } }}>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Set up GoCardless')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
@@ -142,7 +142,7 @@ export const GoCardlessInitialiseModal = ({
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
onPress={() => {
|
||||
void onSubmit(close);
|
||||
void onSubmit(() => state.close());
|
||||
}}
|
||||
>
|
||||
<Trans>Save and continue</Trans>
|
||||
|
||||
@@ -17,11 +17,11 @@ export function GoalTemplateModal() {
|
||||
|
||||
return (
|
||||
<Modal name="goal-templates" containerProps={{ style: { width: 850 } }}>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Goal Templates')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<TableHeader>
|
||||
|
||||
@@ -41,11 +41,11 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
|
||||
|
||||
return (
|
||||
<Modal name="hold-buffer">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Hold for next month')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View>
|
||||
<FieldLabel title={t('Hold this amount:')} />{' '}
|
||||
@@ -64,7 +64,7 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
|
||||
onUpdate={setAmount}
|
||||
onEnter={() => {
|
||||
_onSubmit(amount);
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
|
||||
@@ -794,14 +794,14 @@ export function ImportTransactionsModal({
|
||||
isLoading={loadingState === 'parsing'}
|
||||
containerProps={{ style: { width: 800 } }}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={
|
||||
t('Import transactions') +
|
||||
(filetype ? ` (${filetype.toUpperCase()})` : '')
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
{error && !error.parsed && (
|
||||
<View style={{ alignItems: 'center', marginBottom: 15 }}>
|
||||
@@ -1133,7 +1133,7 @@ export function ImportTransactionsModal({
|
||||
isDisabled={count === 0}
|
||||
isLoading={loadingState === 'importing'}
|
||||
onPress={() => {
|
||||
void onImport(close);
|
||||
void onImport(() => state.close());
|
||||
}}
|
||||
>
|
||||
<Trans count={count}>Import {{ count }} transactions</Trans>
|
||||
|
||||
@@ -464,7 +464,7 @@ export function KeyboardShortcutModal() {
|
||||
|
||||
return (
|
||||
<Modal name="keyboard-shortcuts" containerProps={{ style: { width: 700 } }}>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={
|
||||
@@ -495,7 +495,7 @@ export function KeyboardShortcutModal() {
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -89,11 +89,11 @@ export function LoadBackupModal({
|
||||
|
||||
return (
|
||||
<Modal name="load-backup" containerProps={{ style: { maxWidth: '30vw' } }}>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Load Backup')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View style={{ marginBottom: 30 }}>
|
||||
<View
|
||||
|
||||
@@ -21,11 +21,11 @@ export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
|
||||
|
||||
return (
|
||||
<Modal name="manage-rules" isLoading={loading}>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Rules')}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
|
||||
</>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function MergeUnusedPayeesModal({
|
||||
|
||||
return (
|
||||
<Modal name="merge-unused-payees">
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<View style={{ padding: 20, maxWidth: 500 }}>
|
||||
<View>
|
||||
<Paragraph style={{ marginBottom: 10, fontWeight: 500 }}>
|
||||
@@ -198,7 +198,7 @@ export function MergeUnusedPayeesModal({
|
||||
style={{ marginRight: 10 }}
|
||||
onPress={() => {
|
||||
void onMerge(targetPayee);
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<Trans>Merge</Trans>
|
||||
@@ -208,13 +208,13 @@ export function MergeUnusedPayeesModal({
|
||||
style={{ marginRight: 10 }}
|
||||
onPress={() => {
|
||||
void onMergeAndCreateRule(targetPayee);
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<Trans>Merge and edit rule</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button style={{ marginRight: 10 }} onPress={close}>
|
||||
<Button style={{ marginRight: 10 }} onPress={() => state.close()}>
|
||||
<Trans>Do nothing</Trans>
|
||||
</Button>
|
||||
</ModalButtons>
|
||||
|
||||
@@ -37,11 +37,11 @@ export function NotesModal({ id, name, onSave }: NotesModalProps) {
|
||||
style: { height: '50vh' },
|
||||
}}
|
||||
>
|
||||
{({ state: { close } }) => (
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Notes: {{name}}', { name })}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
@@ -78,7 +78,7 @@ export function NotesModal({ id, name, onSave }: NotesModalProps) {
|
||||
}}
|
||||
onPress={() => {
|
||||
_onSave();
|
||||
close();
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
<SvgCheck width={17} height={17} style={{ paddingRight: 5 }} />
|
||||
|
||||