Compare commits
55 Commits
react-quer
...
ai/custom-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f6ecf3f4 | ||
|
|
0f6d1be4c0 | ||
|
|
aae8335fcf | ||
|
|
bf1d220ced | ||
|
|
94dd8f73c0 | ||
|
|
85e08b2e9e | ||
|
|
60e2665fcc | ||
|
|
102be1c54d | ||
|
|
448da13cf5 | ||
|
|
41679235be | ||
|
|
73fa068fe9 | ||
|
|
1fe588c143 | ||
|
|
edce092ae8 | ||
|
|
b2267b8b0d | ||
|
|
77411394f6 | ||
|
|
235d94478f | ||
|
|
7e0edd43ec | ||
|
|
fdf5c8d0a9 | ||
|
|
a8ec84ceac | ||
|
|
b727124603 | ||
|
|
13fcc408fa | ||
|
|
dd6f27607e | ||
|
|
8bb7f207f2 | ||
|
|
6e0c15eb12 | ||
|
|
95badf1608 | ||
|
|
4e2cec2c7a | ||
|
|
078603cadf | ||
|
|
b3a86b5392 | ||
|
|
295a565e55 | ||
|
|
387c8fce16 | ||
|
|
c7ebfd8ad4 | ||
|
|
e1f834371b | ||
|
|
4caee99955 | ||
|
|
286d05d187 | ||
|
|
cf05a7ea01 | ||
|
|
b373b612a4 | ||
|
|
3797cff716 | ||
|
|
9e2793d413 | ||
|
|
3201819df9 | ||
|
|
eca50f28b0 | ||
|
|
c82ee91b12 | ||
|
|
cb8ff337dc | ||
|
|
c37a5a02aa | ||
|
|
f9e09ca59b | ||
|
|
8081b8829e | ||
|
|
f2f79d378c | ||
|
|
c5cca67399 | ||
|
|
eabf09587f | ||
|
|
6022929551 | ||
|
|
e65429497d | ||
|
|
3758d72b65 | ||
|
|
032d10ac42 | ||
|
|
f97a89dc28 | ||
|
|
a4bd301ec6 | ||
|
|
18072e1d8b |
@@ -13,8 +13,6 @@ reviews:
|
||||
mode: off
|
||||
enabled: false
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI. Add this only if "AI generated" label is not present. Add it always if the commit or PR title is prefixed with "[AI]".'
|
||||
- label: 'API'
|
||||
instructions: 'This issue or PR updates the API in `packages/api`.'
|
||||
- label: 'documentation'
|
||||
|
||||
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)
|
||||
26
.github/scripts/count-points.mjs
vendored
@@ -8,13 +8,13 @@ const CONFIG = {
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
|
||||
PR_CONTRIBUTION_POINTS: {
|
||||
Features: 2,
|
||||
Enhancements: 2,
|
||||
Bugfix: 3,
|
||||
Maintenance: 2,
|
||||
Unknown: 2,
|
||||
},
|
||||
PR_CONTRIBUTION_POINTS: [
|
||||
{ categories: ['Features'], points: 2 },
|
||||
{ categories: ['Enhancements'], points: 2 },
|
||||
{ categories: ['Bugfixes', 'Bugfix'], points: 3 },
|
||||
{ categories: ['Maintenance'], points: 2 },
|
||||
{ categories: ['Unknown'], points: 2 },
|
||||
],
|
||||
// Point tiers for code changes (non-docs)
|
||||
CODE_PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
@@ -130,11 +130,14 @@ async function getPRCategoryAndPoints(
|
||||
'utf-8',
|
||||
);
|
||||
const category = parseReleaseNotesCategory(content);
|
||||
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes(category),
|
||||
);
|
||||
|
||||
if (category && CONFIG.PR_CONTRIBUTION_POINTS[category]) {
|
||||
if (tier) {
|
||||
return {
|
||||
category,
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS[category],
|
||||
points: tier.points,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -142,9 +145,12 @@ async function getPRCategoryAndPoints(
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const unknownTier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes('Unknown'),
|
||||
);
|
||||
return {
|
||||
category: 'Unknown',
|
||||
points: CONFIG.PR_CONTRIBUTION_POINTS.Unknown,
|
||||
points: unknownTier.points,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# 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. 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_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
|
||||
steps:
|
||||
- name: POST to Merge Freeze – add PR to unblocked list
|
||||
env:
|
||||
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
USER_NAME: ${{ github.actor }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
|
||||
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
|
||||
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
|
||||
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
|
||||
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
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
|
||||
|
||||
@@ -103,6 +103,10 @@
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/no-floating-promises": "warn", // TODO: covert to error
|
||||
"typescript/require-array-sort-compare": "warn", // TODO: covert to 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",
|
||||
|
||||
84
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
|
||||
|
||||
@@ -529,7 +508,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
|
||||
2. Reinstall dependencies: `yarn install`
|
||||
3. Check Node.js version (requires >=20)
|
||||
3. Check Node.js version (requires >=22)
|
||||
4. Check Yarn version (requires ^4.9.1)
|
||||
|
||||
## Testing 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
|
||||
|
||||
@@ -619,7 +588,7 @@ yarn install:server
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- **Node.js**: >=20
|
||||
- **Node.js**: >=22
|
||||
- **Yarn**: ^4.9.1 (managed by packageManager field)
|
||||
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
|
||||
|
||||
@@ -632,3 +601,40 @@ The codebase is actively being migrated:
|
||||
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
|
||||
|
||||
When working with older code, follow the newer patterns described in this guide.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Services overview
|
||||
|
||||
| Service | Command | Port | Required |
|
||||
| ------------------- | ----------------------- | ---- | ----------------------------- |
|
||||
| Web Frontend (Vite) | `yarn start` | 3001 | Yes |
|
||||
| Sync Server | `yarn start:server-dev` | 5006 | Optional (sync features only) |
|
||||
|
||||
All storage is **SQLite** (file-based via `better-sqlite3`). No external databases or services are needed.
|
||||
|
||||
### Running the app
|
||||
|
||||
- `yarn start` builds the plugins-service worker, loot-core browser backend, and starts the Vite dev server on port **3001**.
|
||||
- `yarn start:server-dev` starts both the sync server (port 5006) and the web frontend together.
|
||||
- The Vite HMR dev server serves many unbundled modules. In constrained environments, the browser may hit `ERR_INSUFFICIENT_RESOURCES`. If that happens, use `yarn build:browser` followed by serving the built output from `packages/desktop-client/build/` with proper COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`).
|
||||
|
||||
### Lint, test, typecheck
|
||||
|
||||
Standard commands documented in `package.json` scripts and the Quick Start section above:
|
||||
|
||||
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
|
||||
- `yarn test` (lage across all workspaces)
|
||||
- `yarn typecheck` (tsc + lage typecheck)
|
||||
|
||||
### Testing and previewing the app
|
||||
|
||||
When running the app for manual testing or demos, use **"View demo"** on the initial setup screen (after selecting "Don't use a server"). This creates a test budget pre-populated with realistic sample data (accounts, transactions, categories, and budgeted amounts), which is far more useful than starting with an empty budget.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The `engines` field requires **Node.js >=22** and **Yarn ^4.9.1**. The `.nvmrc` specifies `v22/*`.
|
||||
- Pre-commit hook runs `lint-staged` (oxfmt + oxlint) via Husky. Run `yarn prepare` once after install to set up hooks.
|
||||
- Lage caches test results in `.lage/`. If tests behave unexpectedly, clear with `rm -rf .lage`.
|
||||
- Native modules (`better-sqlite3`, `bcrypt`) require build tools (`gcc`, `make`, `python3`). These are pre-installed in the Cloud VM.
|
||||
- All yarn commands must be run from the repository root, never from child workspaces.
|
||||
|
||||
@@ -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": [
|
||||
@@ -13,7 +13,7 @@
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
"build:node": "tsc && tsc-alias",
|
||||
"build:migrations": "mkdir dist/migrations && cp migrations/*.sql dist/migrations",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
|
||||
|
||||
@@ -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,13 +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": ["."] }]
|
||||
},
|
||||
"include": [".", "../../packages/loot-core/typings/pegjs.ts"],
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
|
||||
1
packages/api/typings/pegjs.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.pegjs';
|
||||
@@ -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"]
|
||||
|
||||
@@ -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: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -119,6 +119,8 @@ export function ActionableGridListItem<T extends object>({
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
borderRadius: 0,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
onClick={handleAction}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -52,7 +52,10 @@ export function RulesListItem({
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
|
||||
<SpaceBetween
|
||||
gap={12}
|
||||
style={{ alignItems: 'flex-start', width: '100%' }}
|
||||
>
|
||||
{/* Column 1: PRE/POST pill */}
|
||||
<View
|
||||
style={{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
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
|
||||
|
||||