Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f828b6562 | ||
|
|
ad9a84ddf1 | ||
|
|
d9a171b249 | ||
|
|
e5c84d4ae0 | ||
|
|
94a76a008d | ||
|
|
432c2b6165 | ||
|
|
46eb2a7c38 | ||
|
|
3214d5dd53 | ||
|
|
66d8f1a631 | ||
|
|
e3aa63d1fa | ||
|
|
9478707ebb | ||
|
|
9952412e1d | ||
|
|
8231bbbf5a | ||
|
|
eadd88ce31 | ||
|
|
6c61cf6a8d | ||
|
|
0fb9c252ca | ||
|
|
d73ead135e | ||
|
|
2c87c44168 | ||
|
|
15beba2ca3 | ||
|
|
667cc24fac | ||
|
|
4cc542a658 | ||
|
|
093d799ba0 | ||
|
|
68d10f6b29 | ||
|
|
252f04e02c | ||
|
|
13cb85835b | ||
|
|
39cf04c74d | ||
|
|
562b5e2afd | ||
|
|
4b4e32d0e2 | ||
|
|
d821f1cebc | ||
|
|
53e3694a38 | ||
|
|
8647452ccc | ||
|
|
5a40b017f0 | ||
|
|
8ccc1af77e | ||
|
|
07904c209e | ||
|
|
234f008dcf | ||
|
|
d130b427b3 | ||
|
|
39cd71aa48 | ||
|
|
100711ccfb | ||
|
|
7c9f3f241d | ||
|
|
6509e80061 | ||
|
|
fbd6989a18 | ||
|
|
180431f9ed | ||
|
|
d27d62b5fc | ||
|
|
b5f29ccb4a | ||
|
|
0a5acebeaf | ||
|
|
fa544d9c08 | ||
|
|
8976a59c3a | ||
|
|
9713d09603 | ||
|
|
814f4fe955 | ||
|
|
dbe6b27d9f | ||
|
|
31a7902a08 | ||
|
|
eb35b41c6d | ||
|
|
a025d2b621 | ||
|
|
d72140b8b6 | ||
|
|
254059d4c8 | ||
|
|
92bc1e8ec9 | ||
|
|
b211b67f5e | ||
|
|
a2abb2b2ae | ||
|
|
3fab1be737 | ||
|
|
bf9fbc5137 | ||
|
|
52eced1f21 | ||
|
|
359af05cc4 | ||
|
|
9f1a8f6d5c | ||
|
|
b22d712b4f | ||
|
|
098cacd904 | ||
|
|
127f114914 | ||
|
|
b56e26ee56 | ||
|
|
cd6b141117 | ||
|
|
cd15aded05 | ||
|
|
cac318255d | ||
|
|
6872dd235b | ||
|
|
649932b42f | ||
|
|
d372b71f36 | ||
|
|
47cb5e1ecf | ||
|
|
02c59d9a1c | ||
|
|
f9f6917fcd | ||
|
|
7441b5fa92 | ||
|
|
bfb2d61286 |
24
.cursor/rules/pull-request.mdc
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Before pushing code changes or opening a pull request, follow these steps:
|
||||
|
||||
1. Check if your branch already has a changelog file in the "upcoming-release-notes" folder.
|
||||
2. If there is no changelog file for your branch:
|
||||
a. Find the number of the most recent (highest-numbered) open issue or pull request on GitHub.
|
||||
b. Increment that number by 1. Use this as the filename for your new changelog file.
|
||||
c. Create a new markdown file in the "upcoming-release-notes" folder with the following format:
|
||||
|
||||
```
|
||||
---
|
||||
category: Features OR Maintenance OR Enhancements OR Bugfix
|
||||
authors: [$GithubUsername]
|
||||
---
|
||||
|
||||
$Description
|
||||
```
|
||||
|
||||
3. Commit the new changelog file.
|
||||
4. Proceed with your push or pull request.
|
||||
32
.cursor/rules/typescript.mdc
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description:
|
||||
globs: *.ts,*.tsx
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
You are an expert in TypeScript and React.
|
||||
|
||||
Code Style and Structure
|
||||
|
||||
- Write concise, technical TypeScript code.
|
||||
- Use functional and declarative programming patterns; avoid classes.
|
||||
- Prefer iteration and modularization over code duplication.
|
||||
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
|
||||
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
|
||||
|
||||
Naming Conventions
|
||||
|
||||
- Favor named exports for components and utilities.
|
||||
|
||||
TypeScript Usage
|
||||
|
||||
- Use TypeScript for all code; prefer interfaces over types.
|
||||
- Avoid enums; use objects or maps instead.
|
||||
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
|
||||
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
|
||||
|
||||
Syntax and Formatting
|
||||
|
||||
- Use the "function" keyword for pure functions.
|
||||
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
|
||||
- Use declarative JSX, keeping JSX minimal and readable.
|
||||
14
.cursor/rules/unit-tests.mdc
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Vitest test runner is used for unit tests.
|
||||
|
||||
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
|
||||
|
||||
To run unit tests for a specific package in the monorepo, use the following command:
|
||||
|
||||
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
|
||||
|
||||
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.
|
||||
5
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -4,12 +4,10 @@ title: '[Bug]: '
|
||||
labels: ['needs triage', 'bug']
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report! Please ensure you provide as much information as possible to better assist in confirming and identifying a fix for the bug.
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
**IMPORTANT:** we use GitHub Issues only for BUG REPORTS and FEATURE REQUESTS. If you are looking for help/support - please reach out to the [community on Discord](https://discord.gg/pRYNYr4W5A). All non-bug and non-feature-request issues will be closed.
|
||||
@@ -23,8 +21,6 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
@@ -43,7 +39,6 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
id: env-info
|
||||
attributes:
|
||||
value: '## Environment Details'
|
||||
- type: dropdown
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature-request.yml
vendored
@@ -4,7 +4,6 @@ title: '[Feature] '
|
||||
labels: ['feature']
|
||||
body:
|
||||
- type: markdown
|
||||
id: intro-md
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what you’re proposing so we can come up with the best solution for everyone.
|
||||
@@ -16,8 +15,6 @@ body:
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: '💻'
|
||||
|
||||
75
.github/actions/ai-generated-release-notes/check-first-comment.js
vendored
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const commentId = process.env.GITHUB_EVENT_COMMENT_ID;
|
||||
|
||||
if (!token || !repo || !issueNumber || !commentId) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function checkFirstComment() {
|
||||
try {
|
||||
console.log('Fetching comments with Octokit...');
|
||||
|
||||
// Get all comments with automatic pagination
|
||||
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
|
||||
owner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
|
||||
console.log(`Total comments found: ${comments.length}`);
|
||||
|
||||
// Filter for CodeRabbit summary comments (containing the specific marker)
|
||||
const coderabbitSummaryComments = comments.filter(comment => {
|
||||
const isCodeRabbit = comment.user.login === 'coderabbitai[bot]';
|
||||
const hasSummaryMarker = comment.body.includes(
|
||||
'<!-- This is an auto-generated comment: summarize by coderabbit.ai -->',
|
||||
);
|
||||
|
||||
if (isCodeRabbit) {
|
||||
console.log(
|
||||
`CodeRabbit comment found (ID: ${comment.id}), has summary marker: ${hasSummaryMarker}`,
|
||||
);
|
||||
}
|
||||
|
||||
return isCodeRabbit && hasSummaryMarker;
|
||||
});
|
||||
|
||||
const isFirstSummaryComment =
|
||||
coderabbitSummaryComments.length === 1 &&
|
||||
coderabbitSummaryComments[0].id == commentId;
|
||||
|
||||
console.log(
|
||||
`CodeRabbit summary comments found: ${coderabbitSummaryComments.length}`,
|
||||
);
|
||||
console.log(`Current comment ID: ${commentId}`);
|
||||
console.log(`Is first summary comment: ${isFirstSummaryComment}`);
|
||||
setOutput('result', isFirstSummaryComment);
|
||||
} catch (error) {
|
||||
console.log('Error checking CodeRabbit comment:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'false');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
checkFirstComment().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'false');
|
||||
process.exit(1);
|
||||
});
|
||||
76
.github/actions/ai-generated-release-notes/check-release-notes-exists.js
vendored
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
|
||||
if (!token || !repo || !issueNumber || !prDetailsJson) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function checkReleaseNotesExists() {
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
if (!prDetails) {
|
||||
console.log('No PR details available, skipping file check');
|
||||
setOutput('result', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = `upcoming-release-notes/${prDetails.number}.md`;
|
||||
|
||||
// Get PR info to get head SHA
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prHeadSha = pr.head.sha;
|
||||
console.log(
|
||||
`Checking for file on PR branch: ${pr.head.ref} (${prHeadSha})`,
|
||||
);
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await octokit.rest.repos.getContent({
|
||||
owner,
|
||||
repo: repoName,
|
||||
path: fileName,
|
||||
ref: prHeadSha,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Release notes file already exists on PR branch: ${fileName}`,
|
||||
);
|
||||
setOutput('result', 'true');
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(
|
||||
`No existing release notes file found on PR branch: ${fileName}`,
|
||||
);
|
||||
setOutput('result', 'false');
|
||||
} else {
|
||||
console.log('Error checking file existence:', error.message);
|
||||
setOutput('result', 'false');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error in file existence check:', error.message);
|
||||
setOutput('result', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
checkReleaseNotesExists();
|
||||
76
.github/actions/ai-generated-release-notes/comment-on-pr.js
vendored
Executable file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const category = process.env.CATEGORY;
|
||||
|
||||
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
async function commentOnPR() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category || category === 'null') {
|
||||
console.log('No valid category available, skipping comment');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean category for display
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
|
||||
// Get PR info for the file URL
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prBranch = pr.head.ref;
|
||||
const headOwner = pr.head.repo.owner.login;
|
||||
const headRepo = pr.head.repo.name;
|
||||
const fileUrl = `https://github.com/${headOwner}/${headRepo}/blob/${prBranch}/upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
const commentBody = [
|
||||
'🤖 **Auto-generated Release Notes**',
|
||||
'',
|
||||
`Hey @${summaryData.author}! I've automatically created a release notes file based on CodeRabbit's analysis:`,
|
||||
'',
|
||||
`**Category:** ${cleanCategory}`,
|
||||
`**Summary:** ${summaryData.summary}`,
|
||||
`**File:** [upcoming-release-notes/${summaryData.prNumber}.md](${fileUrl})`,
|
||||
'',
|
||||
'The release notes file has been committed to the repository. You can edit it if needed before merging.',
|
||||
].join('\n');
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo: repoName,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
console.log('✅ Successfully commented on PR');
|
||||
} catch (error) {
|
||||
console.log('Error commenting on PR:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
commentOnPR();
|
||||
96
.github/actions/ai-generated-release-notes/create-release-notes-file.js
vendored
Executable file
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const category = process.env.CATEGORY;
|
||||
|
||||
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
async function createReleaseNotesFile() {
|
||||
try {
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
console.log('Debug - Category value:', category);
|
||||
console.log('Debug - Category type:', typeof category);
|
||||
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
|
||||
|
||||
if (!summaryData) {
|
||||
console.log('No summary data available, cannot create file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category || category === 'null') {
|
||||
console.log('No valid category available, cannot create file');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create file content - ensure category is not quoted
|
||||
const cleanCategory =
|
||||
typeof category === 'string'
|
||||
? category.replace(/^["']|["']$/g, '')
|
||||
: category;
|
||||
console.log('Debug - Clean category:', cleanCategory);
|
||||
|
||||
const fileContent = `---
|
||||
category: ${cleanCategory}
|
||||
authors: [${summaryData.author}]
|
||||
---
|
||||
|
||||
${summaryData.summary}`;
|
||||
|
||||
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
|
||||
|
||||
console.log(`Creating release notes file: ${fileName}`);
|
||||
console.log('File content:');
|
||||
console.log(fileContent);
|
||||
|
||||
// Get PR info
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
const prBranch = pr.head.ref;
|
||||
const headOwner = pr.head.repo.owner.login;
|
||||
const headRepo = pr.head.repo.name;
|
||||
|
||||
console.log(
|
||||
`Committing to PR branch: ${headOwner}/${headRepo}:${prBranch}`,
|
||||
);
|
||||
|
||||
// Create the file via GitHub API on the PR branch
|
||||
await octokit.rest.repos.createOrUpdateFileContents({
|
||||
owner: headOwner,
|
||||
repo: headRepo,
|
||||
path: fileName,
|
||||
message: `Add release notes for PR #${summaryData.prNumber}`,
|
||||
content: Buffer.from(`${fileContent}\n\n`).toString('base64'),
|
||||
branch: prBranch,
|
||||
committer: {
|
||||
name: 'github-actions[bot]',
|
||||
email: 'github-actions[bot]@users.noreply.github.com',
|
||||
},
|
||||
author: {
|
||||
name: 'github-actions[bot]',
|
||||
email: 'github-actions[bot]@users.noreply.github.com',
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Successfully created release notes file: ${fileName}`);
|
||||
} catch (error) {
|
||||
console.log('Error creating release notes file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
createReleaseNotesFile();
|
||||
118
.github/actions/ai-generated-release-notes/determine-category.js
vendored
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
const summaryDataJson = process.env.SUMMARY_DATA;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!commentBody || !prDetailsJson || !summaryDataJson || !openaiApiKey) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
const summaryData = JSON.parse(summaryDataJson);
|
||||
|
||||
if (!summaryData || !prDetails) {
|
||||
console.log('Missing data for categorization');
|
||||
setOutput('result', 'null');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
|
||||
},
|
||||
],
|
||||
max_tokens: 10,
|
||||
temperature: 0.1,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.openai.com',
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let responseData = '';
|
||||
res.on('data', chunk => (responseData += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log('OpenAI API error for categorization');
|
||||
setOutput('result', 'null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
console.log('OpenAI raw response:', JSON.stringify(response, null, 2));
|
||||
|
||||
const rawContent = response.choices[0].message.content.trim();
|
||||
console.log('Raw content from OpenAI:', rawContent);
|
||||
|
||||
let category;
|
||||
try {
|
||||
category = JSON.parse(rawContent);
|
||||
console.log('Parsed category:', category);
|
||||
} catch (parseError) {
|
||||
console.log(
|
||||
'JSON parse error, using raw content:',
|
||||
parseError.message,
|
||||
);
|
||||
category = rawContent;
|
||||
}
|
||||
|
||||
// Validate the category response
|
||||
const validCategories = [
|
||||
'Features',
|
||||
'Bugfix',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
if (validCategories.includes(category)) {
|
||||
console.log('OpenAI categorized as:', category);
|
||||
setOutput('result', category);
|
||||
} else {
|
||||
console.log('Invalid category from OpenAI:', category);
|
||||
console.log('Valid categories are:', validCategories);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing OpenAI response:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
console.log('Error in categorization:', error.message);
|
||||
setOutput('result', 'null');
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.log('Error in categorization:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
97
.github/actions/ai-generated-release-notes/generate-summary.js
vendored
Executable file
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
|
||||
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
|
||||
const prDetailsJson = process.env.PR_DETAILS;
|
||||
const openaiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
if (!commentBody || !prDetailsJson || !openaiApiKey) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
const prDetails = JSON.parse(prDetailsJson);
|
||||
|
||||
if (!prDetails) {
|
||||
console.log('No PR details available, cannot generate summary');
|
||||
setOutput('result', 'null');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('CodeRabbit comment body:', commentBody);
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a technical writer helping to create concise release notes. Generate a maximum 15-word summary that describes what this PR does. Focus on the user-facing changes or bug fixes. Do not include "This PR" or similar phrases - just describe the change directly. Start with a base form verb (e.g., "Add" not "Adds", "Fix" not "Fixes", "Introduce" not "Introduces").',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `PR Title: ${prDetails.title}\n\nCodeRabbit Analysis:\n${commentBody}\n\nPlease provide a concise summary (max 15 words) of what this PR accomplishes.`,
|
||||
},
|
||||
],
|
||||
max_tokens: 50,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: 'api.openai.com',
|
||||
path: '/v1/chat/completions',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openaiApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(options, res => {
|
||||
let responseData = '';
|
||||
res.on('data', chunk => (responseData += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.log(`OpenAI API error: ${res.statusCode} ${res.statusMessage}`);
|
||||
setOutput('result', 'null');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(responseData);
|
||||
const summary = response.choices[0].message.content.trim();
|
||||
|
||||
console.log('Generated summary:', summary);
|
||||
|
||||
const result = {
|
||||
summary: summary,
|
||||
prNumber: prDetails.number,
|
||||
author: prDetails.author,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.log('Error parsing OpenAI response:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', error => {
|
||||
console.log('Error generating summary:', error.message);
|
||||
setOutput('result', 'null');
|
||||
});
|
||||
|
||||
req.write(data);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.log('Error generating summary:', error.message);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
59
.github/actions/ai-generated-release-notes/pr-details.js
vendored
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import fs from 'fs';
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
|
||||
|
||||
if (!token || !repo || !issueNumber) {
|
||||
console.log('Missing required environment variables');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [owner, repoName] = repo.split('/');
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
function setOutput(name, value) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
|
||||
}
|
||||
|
||||
async function getPRDetails() {
|
||||
try {
|
||||
console.log(
|
||||
`Fetching PR details for ${owner}/${repoName}#${issueNumber}...`,
|
||||
);
|
||||
|
||||
const { data: pr } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
});
|
||||
|
||||
console.log('PR details fetched successfully');
|
||||
console.log('- PR Number:', pr.number);
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
} catch (error) {
|
||||
console.log('Error getting PR details:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
getPRDetails().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
process.exit(1);
|
||||
});
|
||||
94
.github/actions/get-next-package-version.js
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
const { parseArgs } = require('node:util');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const args = process.argv;
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
type: 'string',
|
||||
short: 'p',
|
||||
},
|
||||
type: {
|
||||
type: 'string', // nightly, hotfix, monthly
|
||||
short: 't',
|
||||
},
|
||||
};
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
|
||||
// Read and parse package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
// Parse year and month from version (e.g. 25.5.1 -> year=2025, month=5)
|
||||
const versionParts = currentVersion.split('.');
|
||||
const versionYear = parseInt(versionParts[0]);
|
||||
const versionMonth = parseInt(versionParts[1]);
|
||||
const versionHotfix = parseInt(versionParts[2]);
|
||||
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const nextVersionYear = nextVersionMonthDate
|
||||
.getFullYear()
|
||||
.toString()
|
||||
.slice(-2);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
|
||||
// Get current date string
|
||||
const currentDate = new Date()
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
|
||||
switch (values.type) {
|
||||
case 'nightly': {
|
||||
const newVersion = `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDate}`;
|
||||
process.stdout.write(newVersion); // return the new version to stdout
|
||||
process.exit();
|
||||
}
|
||||
case 'hotfix': {
|
||||
const bugfixVersion = `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
|
||||
process.stdout.write(bugfixVersion); // return the bugfix version to stdout
|
||||
process.exit();
|
||||
}
|
||||
case 'monthly': {
|
||||
const stableVersion = `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
process.stdout.write(stableVersion); // return the stable version to stdout
|
||||
process.exit();
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
'Invalid type specified. Use "nightly", "hotfix", or "monthly".',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
1
.github/actions/setup/action.yml
vendored
@@ -1,4 +1,5 @@
|
||||
name: Setup
|
||||
description: Setup the environment for the project
|
||||
|
||||
inputs:
|
||||
working-directory:
|
||||
|
||||
350
.github/scripts/count-points.mjs
vendored
Normal file
@@ -0,0 +1,350 @@
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { minimatch } from 'minimatch';
|
||||
|
||||
/** Repository-specific configuration for points calculation */
|
||||
const REPOSITORY_CONFIG = new Map([
|
||||
[
|
||||
'actual',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 0,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: [
|
||||
'yarn.lock',
|
||||
'.yarn/**/*',
|
||||
'packages/component-library/src/icons/**/*',
|
||||
'release-notes/**/*',
|
||||
],
|
||||
},
|
||||
],
|
||||
[
|
||||
'docs',
|
||||
{
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4,
|
||||
PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 1000, points: 6 },
|
||||
{ minChanges: 100, points: 4 },
|
||||
{ minChanges: 0, points: 2 },
|
||||
],
|
||||
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Get the start and end dates for the last month.
|
||||
* @returns {Object} An object containing the start and end dates.
|
||||
*/
|
||||
function getLastMonthDates() {
|
||||
// Get data relating to the last month
|
||||
const now = new Date();
|
||||
const firstDayOfLastMonth = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth() - 1,
|
||||
1,
|
||||
);
|
||||
const since = process.env.START_DATE
|
||||
? new Date(process.env.START_DATE)
|
||||
: firstDayOfLastMonth;
|
||||
|
||||
// Calculate the end of the month for the since date
|
||||
const until = new Date(
|
||||
since.getFullYear(),
|
||||
since.getMonth() + 1,
|
||||
0,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
|
||||
return { since, until };
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for calculating the monthly points each core contributor has earned.
|
||||
* These are used for payouts depending.
|
||||
* @param {string} repo - The repository to analyze ('actual' or 'docs')
|
||||
* @returns {number} The total points earned for the repository
|
||||
*/
|
||||
async function countContributorPoints(repo) {
|
||||
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
|
||||
const owner = 'actualbudget';
|
||||
const config = REPOSITORY_CONFIG.get(repo);
|
||||
|
||||
const { since, until } = getLastMonthDates();
|
||||
|
||||
// Get organization members
|
||||
const { data: orgMembers } = await octokit.orgs.listMembers({
|
||||
org: owner,
|
||||
});
|
||||
const orgMemberLogins = new Set(orgMembers.map(member => member.login));
|
||||
|
||||
// Initialize stats map with all org members
|
||||
const stats = new Map(
|
||||
Array.from(orgMemberLogins).map(login => [
|
||||
login,
|
||||
{
|
||||
reviews: [], // Will store objects with PR number and points
|
||||
labelRemovals: [],
|
||||
issueClosings: [],
|
||||
points: 0,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
// Helper function to print statistics
|
||||
const printStats = (title, getValue, formatLine) => {
|
||||
console.log(`\n${title}:`);
|
||||
console.log('='.repeat(title.length + 1));
|
||||
|
||||
const entries = Array.from(stats.entries())
|
||||
.map(([user, userStats]) => [user, getValue(userStats)])
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log(`No ${title.toLowerCase()} found in the last month.`);
|
||||
} else {
|
||||
entries.forEach(([user, count]) => {
|
||||
console.log(formatLine(user, count));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get all PRs using search
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
|
||||
const recentPRs = await octokit.paginate(
|
||||
octokit.search.issuesAndPullRequests,
|
||||
{
|
||||
q: searchQuery,
|
||||
per_page: 100,
|
||||
advanced_search: true,
|
||||
},
|
||||
response => response.data,
|
||||
);
|
||||
|
||||
// Get reviews and PR details for each PR
|
||||
for (const pr of recentPRs) {
|
||||
const { data: reviews } = await octokit.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Get list of modified files
|
||||
const { data: modifiedFiles } = await octokit.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Calculate points based on PR size, excluding specified files
|
||||
const totalChanges = modifiedFiles
|
||||
.filter(
|
||||
file =>
|
||||
!config.EXCLUDED_FILES.some(pattern =>
|
||||
minimatch(file.filename, pattern),
|
||||
),
|
||||
)
|
||||
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
|
||||
|
||||
// Check if this is a release PR
|
||||
const isReleasePR = pr.title.match(/^🔖 \(\d+\.\d+\.\d+\)/);
|
||||
|
||||
// Calculate points for reviewers based on PR size
|
||||
const prPoints = config.PR_REVIEW_POINT_TIERS.find(
|
||||
tier => totalChanges > tier.minChanges,
|
||||
).points;
|
||||
|
||||
// Add points to the reviewers
|
||||
const uniqueReviewers = new Set();
|
||||
reviews
|
||||
.filter(
|
||||
review =>
|
||||
stats.has(review.user?.login) &&
|
||||
review.state === 'APPROVED' &&
|
||||
!uniqueReviewers.has(review.user?.login),
|
||||
)
|
||||
.forEach(({ user: { login: reviewer } }) => {
|
||||
uniqueReviewers.add(reviewer);
|
||||
const userStats = stats.get(reviewer);
|
||||
userStats.reviews.push({ pr: pr.number.toString(), points: prPoints });
|
||||
userStats.points += prPoints;
|
||||
});
|
||||
|
||||
// Award points to the PR creator if it's a release PR
|
||||
if (isReleasePR && stats.has(pr.user.login)) {
|
||||
const creatorStats = stats.get(pr.user.login);
|
||||
creatorStats.reviews.push({
|
||||
pr: pr.number.toString(),
|
||||
points: config.POINTS_PER_RELEASE_PR,
|
||||
isReleaseCreator: true,
|
||||
});
|
||||
creatorStats.points += config.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
}
|
||||
|
||||
// Get all issues with label events in the last month
|
||||
const issues = await octokit.paginate(
|
||||
octokit.issues.listForRepo,
|
||||
{
|
||||
owner,
|
||||
repo,
|
||||
state: 'all',
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
per_page: 100,
|
||||
since: since.toISOString(),
|
||||
},
|
||||
(response, done) =>
|
||||
response.data.filter(issue => new Date(issue.updated_at) <= until),
|
||||
);
|
||||
|
||||
// Get label events for each issue
|
||||
for (const issue of issues) {
|
||||
const { data: events } = await octokit.issues.listEventsForTimeline({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
});
|
||||
|
||||
// Process events
|
||||
events
|
||||
.filter(
|
||||
event =>
|
||||
new Date(event.created_at) > since &&
|
||||
new Date(event.created_at) <= until &&
|
||||
stats.has(event.actor?.login),
|
||||
)
|
||||
.forEach(event => {
|
||||
if (
|
||||
event.event === 'unlabeled' &&
|
||||
event.label &&
|
||||
event.label.name.toLowerCase() === 'needs triage'
|
||||
) {
|
||||
const remover = event.actor.login;
|
||||
const userStats = stats.get(remover);
|
||||
userStats.labelRemovals.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
|
||||
}
|
||||
|
||||
if (event.event === 'closed') {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
userStats.issueClosings.push(issue.number.toString());
|
||||
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Print all statistics
|
||||
printStats(
|
||||
`PR Review Statistics (${repo})`,
|
||||
stats => stats.reviews.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
.reviews.map(r => {
|
||||
if (r.isReleaseCreator) {
|
||||
return `#${r.pr} (${r.points}pts - Release Creator)`;
|
||||
}
|
||||
return `#${r.pr} (${r.points}pts)`;
|
||||
})
|
||||
.join(', ')})`,
|
||||
);
|
||||
printStats(
|
||||
`"Needs Triage" Label Removal Statistics (${repo})`,
|
||||
stats => stats.labelRemovals.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
|
||||
);
|
||||
printStats(
|
||||
`Issue Closing Statistics (${repo})`,
|
||||
stats => stats.issueClosings.length,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
|
||||
);
|
||||
|
||||
// Print points summary
|
||||
printStats(
|
||||
`Points Summary (${repo})`,
|
||||
stats => stats.points,
|
||||
(user, userPoints) => `${user}: ${userPoints}`,
|
||||
);
|
||||
|
||||
// Calculate and print total points
|
||||
const totalPoints = Array.from(stats.values()).reduce(
|
||||
(sum, userStats) => sum + userStats.points,
|
||||
0,
|
||||
);
|
||||
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
|
||||
|
||||
// Return the points
|
||||
return new Map(
|
||||
Array.from(stats.entries()).map(([login, userStats]) => [
|
||||
login,
|
||||
userStats.points,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the points for both repositories and print cumulative results
|
||||
*/
|
||||
async function calculateCumulativePoints() {
|
||||
// Get stats for each repository
|
||||
const repoPointsResults = await Promise.all(
|
||||
Array.from(REPOSITORY_CONFIG.keys()).map(countContributorPoints),
|
||||
);
|
||||
|
||||
// Calculate cumulative stats
|
||||
const cumulativeStats = new Map(repoPointsResults[0]);
|
||||
|
||||
// Combine stats from all repositories
|
||||
for (let i = 1; i < repoPointsResults.length; i++) {
|
||||
for (const [login, points] of repoPointsResults[i].entries()) {
|
||||
if (!cumulativeStats.has(login)) {
|
||||
cumulativeStats.set(login, 0);
|
||||
}
|
||||
|
||||
cumulativeStats.set(login, cumulativeStats.get(login) + points);
|
||||
}
|
||||
}
|
||||
|
||||
// Print cumulative statistics
|
||||
console.log('\n\nCUMULATIVE STATISTICS ACROSS ALL REPOSITORIES');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
console.log('\nCumulative Points Summary:');
|
||||
console.log('='.repeat('Cumulative Points Summary'.length + 1));
|
||||
|
||||
const entries = Array.from(cumulativeStats.entries())
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('No cumulative points summary found.');
|
||||
} else {
|
||||
entries.forEach(([user, points]) => {
|
||||
console.log(`${user}: ${points}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate and print total cumulative points
|
||||
const totalCumulativePoints = Array.from(cumulativeStats.values()).reduce(
|
||||
(sum, points) => sum + points,
|
||||
0,
|
||||
);
|
||||
console.log('\nTotal cumulative points earned: ' + totalCumulativePoints);
|
||||
}
|
||||
|
||||
// Run the calculations
|
||||
calculateCumulativePoints().catch(console.error);
|
||||
89
.github/workflows/ai-generated-release-notes.yml
vendored
Normal file
@@ -0,0 +1,89 @@
|
||||
name: Generate Release Notes from CodeRabbit summary
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
# Only run on PR comments from CodeRabbit bot
|
||||
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Check if this is CodeRabbit's first comment
|
||||
id: check-first-comment
|
||||
run: node .github/actions/ai-generated-release-notes/check-first-comment.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
|
||||
|
||||
- name: Get PR details
|
||||
if: steps.check-first-comment.outputs.result == 'true'
|
||||
id: pr-details
|
||||
run: node .github/actions/ai-generated-release-notes/pr-details.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Generate summary with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
|
||||
id: generate-summary
|
||||
run: node .github/actions/ai-generated-release-notes/generate-summary.js
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Determine category with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
|
||||
id: determine-category
|
||||
run: node .github/actions/ai-generated-release-notes/determine-category.js
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
- name: Create and commit release notes file via GitHub API
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
23
.github/workflows/autofix.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: autofix.ci
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Format code
|
||||
run: yarn lint:fix
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
10
.github/workflows/check.yml
vendored
@@ -27,6 +27,16 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
validate-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
- name: Check that the built CLI works
|
||||
run: node packages/sync-server/build/bin/actual-server.js --version
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
26
.github/workflows/count-points.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Count points
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 00:00 on the first day of every month
|
||||
- cron: '0 0 1 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
startDate:
|
||||
description: 'Start date for point counter (YYYY-MM-DD)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
count-points:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Count points
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
START_DATE: ${{ inputs.startDate }}
|
||||
run: node .github/scripts/count-points.mjs
|
||||
19
.github/workflows/docker-edge.yml
vendored
@@ -80,7 +80,24 @@ jobs:
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build and push image
|
||||
- name: Build image for testing
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
file: packages/sync-server/docker/${{ matrix.os }}.Dockerfile
|
||||
tags: actualbudget/actual-server-testing
|
||||
|
||||
- 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
|
||||
|
||||
# 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/
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Extract and upload i18n strings
|
||||
on:
|
||||
schedule:
|
||||
# 4am UTC
|
||||
- cron: "0 4 * * *"
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: ./actual/.github/actions/setup
|
||||
with:
|
||||
working-directory: actual
|
||||
download-translations: false # As we'll manually clone instead
|
||||
download-translations: false # As we'll manually clone instead
|
||||
- name: Configure Git config
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
actualbudget/actual
|
||||
|
||||
- name: Unlock translations
|
||||
if: always() # Clean up even on failure
|
||||
if: always() # Clean up even on failure
|
||||
run: |
|
||||
wlc \
|
||||
--url https://hosted.weblate.org/api/ \
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
body: |
|
||||
:sparkles: Thanks for sharing your idea! :sparkles:
|
||||
|
||||
This repository uses lodash style issue management for enhancements. That means enhancement issues are automatically closed. This doesn’t mean we don’t accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).
|
||||
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).
|
||||
|
||||
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+
|
||||
|
||||
|
||||
10
.github/workflows/netlify-release.yml
vendored
@@ -22,15 +22,15 @@ jobs:
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
|
||||
- name: Install Netlify
|
||||
run: npm install netlify-cli@17.10.1 -g
|
||||
|
||||
|
||||
- name: Build Actual
|
||||
run: yarn build:browser
|
||||
run: yarn build:browser
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: netlify_deploy
|
||||
@@ -40,4 +40,4 @@ jobs:
|
||||
--site ${{ secrets.NETLIFY_SITE_ID }} \
|
||||
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
|
||||
--filter @actual-app/web \
|
||||
--prod
|
||||
--prod
|
||||
|
||||
95
.github/workflows/publish-nightly-npm-packages.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Publish nightly npm packages
|
||||
|
||||
# Nightly npm packages are built daily
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_WEB_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
|
||||
- name: Yarn install
|
||||
run: |
|
||||
yarn install
|
||||
|
||||
- name: Build Server & Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Pack the web and server packages
|
||||
run: |
|
||||
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
|
||||
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
|
||||
|
||||
- name: Build API
|
||||
run: yarn build:api
|
||||
|
||||
- name: Pack the api package
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish Nightly npm packages
|
||||
needs: build-and-pack
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Web
|
||||
run: |
|
||||
npm publish desktop-client/@actual-app/web.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish Sync-Server
|
||||
run: |
|
||||
npm publish sync-server/@actual-app/sync-server.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish API
|
||||
run: |
|
||||
npm publish api/@actual-app/api.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
16
.github/workflows/stale.yml
vendored
@@ -2,6 +2,7 @@ name: 'Close stale PRs'
|
||||
on:
|
||||
schedule:
|
||||
- cron: '30 1 * * *'
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
@@ -24,3 +25,18 @@ jobs:
|
||||
any-of-labels: ':construction: WIP'
|
||||
days-before-close: -1
|
||||
days-before-issue-stale: -1
|
||||
|
||||
stale-needs-info:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
stale-issue-label: 'needs info'
|
||||
days-before-stale: -1
|
||||
days-before-close: 7
|
||||
close-issue-message: 'This issue has been automatically closed because there have been no comments for 7 days after the "needs info" label was added. If you still need help, please feel free to reopen the issue with the requested information.'
|
||||
remove-stale-when-updated: false
|
||||
stale-pr-message: '' # Disable PR processing
|
||||
close-pr-message: '' # Disable PR processing
|
||||
days-before-pr-stale: -1 # Disable PR processing
|
||||
days-before-pr-close: -1 # Disable PR processing
|
||||
|
||||
@@ -20,11 +20,8 @@ packages/desktop-client/playwright-report/
|
||||
packages/desktop-electron/client-build/
|
||||
packages/desktop-electron/build/
|
||||
packages/desktop-electron/dist/
|
||||
packages/import-ynab4/**/node_modules/*
|
||||
packages/import-ynab5/**/node_modules/*
|
||||
packages/loot-core/**/node_modules/*
|
||||
packages/loot-core/**/lib-dist/*
|
||||
packages/loot-core/**/proto/*
|
||||
.yarn/*
|
||||
.github/*
|
||||
upcoming-release-notes/*
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
# you are doing.
|
||||
###################################################
|
||||
|
||||
FROM node:18-bullseye as dev
|
||||
FROM node:20-bullseye as dev
|
||||
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
|
||||
WORKDIR /app
|
||||
CMD ["sh", "./bin/docker-start"]
|
||||
|
||||
@@ -16,7 +16,7 @@ async function run() {
|
||||
`Found potentially matching PR ${activePr.number}: ${activePr.title}`,
|
||||
);
|
||||
}
|
||||
const prNumber = activePr?.number ?? (await getNextPrNumber());
|
||||
const initialPrNumber = activePr?.number ?? (await getNextPrNumber());
|
||||
|
||||
const result = await prompts([
|
||||
{
|
||||
@@ -29,7 +29,7 @@ async function run() {
|
||||
name: 'pullRequestNumber',
|
||||
message: 'PR Number',
|
||||
type: 'number',
|
||||
initial: prNumber,
|
||||
initial: initialPrNumber,
|
||||
},
|
||||
{
|
||||
name: 'releaseNoteType',
|
||||
@@ -53,7 +53,8 @@ async function run() {
|
||||
if (
|
||||
!result.githubUsername ||
|
||||
!result.oneLineSummary ||
|
||||
!result.releaseNoteType
|
||||
!result.releaseNoteType ||
|
||||
!result.pullRequestNumber
|
||||
) {
|
||||
console.log('All questions must be answered. Exiting');
|
||||
exit(1);
|
||||
@@ -64,6 +65,7 @@ async function run() {
|
||||
result.githubUsername,
|
||||
result.oneLineSummary,
|
||||
);
|
||||
const prNumber = result.pullRequestNumber;
|
||||
|
||||
const filepath = `./upcoming-release-notes/${prNumber}.md`;
|
||||
if (existsSync(filepath)) {
|
||||
@@ -83,9 +85,7 @@ async function run() {
|
||||
console.error('Failed to write release note file:', err);
|
||||
exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`Release note generated successfully: ./upcoming-release-notes/${prNumber}.md`,
|
||||
);
|
||||
console.log(`Release note generated successfully: ${filepath}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,11 +108,10 @@ export default pluginTypescript.config(
|
||||
'packages/desktop-electron/client-build/',
|
||||
'packages/desktop-electron/build/',
|
||||
'packages/desktop-electron/dist/',
|
||||
'packages/import-ynab4/**/node_modules/*',
|
||||
'packages/import-ynab5/**/node_modules/*',
|
||||
'packages/loot-core/**/node_modules/*',
|
||||
'packages/loot-core/**/lib-dist/*',
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/build/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
],
|
||||
@@ -504,6 +503,32 @@ export default pluginTypescript.config(
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-router',
|
||||
importNames: ['useNavigate'],
|
||||
message:
|
||||
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useDispatch'],
|
||||
message:
|
||||
"Please import Actual's useDispatch() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useSelector'],
|
||||
message:
|
||||
"Please import Actual's useSelector() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useStore'],
|
||||
message:
|
||||
"Please import Actual's useStore() hook from `src/redux` instead.",
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['*.api', '*.web', '*.electron'],
|
||||
@@ -519,6 +544,10 @@ export default pluginTypescript.config(
|
||||
importNames: ['colors'],
|
||||
message: 'Please use themes instead of colors',
|
||||
},
|
||||
{
|
||||
group: ['@actual-app/web/*'],
|
||||
message: 'Please do not import `@actual-app/web` in `loot-core`',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -652,88 +681,6 @@ export default pluginTypescript.config(
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/desktop-client/**/*'],
|
||||
ignores: ['packages/desktop-client/src/hooks/useNavigate.{ts,tsx}'],
|
||||
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-router-dom',
|
||||
importNames: ['useNavigate'],
|
||||
message:
|
||||
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/desktop-client/**/*', 'packages/loot-core/**/*'],
|
||||
ignores: ['packages/desktop-client/src/redux/index.{ts,tsx}'],
|
||||
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useDispatch'],
|
||||
message:
|
||||
"Please import Actual's useDispatch() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useSelector'],
|
||||
message:
|
||||
"Please import Actual's useSelector() hook from `src/redux` instead.",
|
||||
},
|
||||
{
|
||||
name: 'react-redux',
|
||||
importNames: ['useStore'],
|
||||
message:
|
||||
"Please import Actual's useStore() hook from `src/redux` instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/loot-core/src/**/*'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['*.api', '*.web', '*.electron'],
|
||||
message: "Don't directly reference imports from other platforms",
|
||||
},
|
||||
{
|
||||
group: ['uuid'],
|
||||
importNames: ['*'],
|
||||
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
|
||||
},
|
||||
{
|
||||
group: ['loot-core/**'],
|
||||
message:
|
||||
'Please use relative imports in loot-core instead of importing from `loot-core/*`',
|
||||
},
|
||||
{
|
||||
group: ['@actual-app/web/*'],
|
||||
message: 'Please do not import `@actual-app/web` in `loot-core`',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/loot-core/src/types/**/*',
|
||||
@@ -748,27 +695,6 @@ export default pluginTypescript.config(
|
||||
'import/no-unused-modules': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/desktop-client/src/style/index.*',
|
||||
'packages/desktop-client/src/style/palette.*',
|
||||
],
|
||||
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'off',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['**/style', '**/colors'],
|
||||
importNames: ['colors'],
|
||||
message: 'Please use themes instead of colors',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/api/migrations/*', 'packages/loot-core/migrations/*'],
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@types/node": "^22.15.18",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
@@ -70,6 +71,7 @@
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.5.2",
|
||||
"minimatch": "^10.0.3",
|
||||
"node-jq": "^6.0.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.5.3",
|
||||
@@ -89,7 +91,7 @@
|
||||
"yarn": "^4.9.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx,md,json,yml}": [
|
||||
"*.{js,mjs,jsx,ts,tsx,md,json,yml}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
|
||||
@@ -568,8 +568,20 @@ describe('API CRUD operations', () => {
|
||||
const accountId = await api.createAccount({ name: 'test-account' }, 0);
|
||||
|
||||
let newTransaction = [
|
||||
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: 'notes' },
|
||||
{ date: '2023-11-03', imported_id: '12', amount: 100, notes: '' },
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-11-03',
|
||||
imported_id: '11',
|
||||
amount: 100,
|
||||
notes: 'notes',
|
||||
},
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-11-03',
|
||||
imported_id: '12',
|
||||
amount: 100,
|
||||
notes: '',
|
||||
},
|
||||
];
|
||||
|
||||
const addResult = await api.addTransactions(accountId, newTransaction, {
|
||||
@@ -597,9 +609,27 @@ describe('API CRUD operations', () => {
|
||||
expect(transactions).toHaveLength(2);
|
||||
|
||||
newTransaction = [
|
||||
{ date: '2023-12-03', imported_id: '11', amount: 100, notes: 'notes' },
|
||||
{ date: '2023-12-03', imported_id: '12', amount: 100, notes: 'notes' },
|
||||
{ date: '2023-12-03', imported_id: '22', amount: 200, notes: '' },
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-12-03',
|
||||
imported_id: '11',
|
||||
amount: 100,
|
||||
notes: 'notes',
|
||||
},
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-12-03',
|
||||
imported_id: '12',
|
||||
amount: 100,
|
||||
notes: 'notes',
|
||||
},
|
||||
{
|
||||
account: accountId,
|
||||
date: '2023-12-03',
|
||||
imported_id: '22',
|
||||
amount: 200,
|
||||
notes: '',
|
||||
},
|
||||
];
|
||||
|
||||
const reconciled = await api.importTransactions(accountId, newTransaction);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type { ImportTransactionEntity } from 'loot-core/types/models/import-transaction';
|
||||
|
||||
import * as injected from './injected';
|
||||
|
||||
@@ -98,8 +99,8 @@ export interface ImportTransactionsOpts {
|
||||
}
|
||||
|
||||
export function importTransactions(
|
||||
accountId,
|
||||
transactions,
|
||||
accountId: string,
|
||||
transactions: ImportTransactionEntity[],
|
||||
opts: ImportTransactionsOpts = {
|
||||
defaultCleared: true,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "25.6.0",
|
||||
"version": "25.7.0",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
@@ -27,12 +27,11 @@
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^9.0.8",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.3"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/*"]
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@types/react": "^19.1.4",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"vitest": "^3.1.3"
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"exports": {
|
||||
"./hooks/*": "./src/hooks/*.ts",
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^9.0.1"
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^9.0.8",
|
||||
"ts-protoc-gen": "^0.15.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vitest": "^3.1.3"
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no"
|
||||
content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<title>Actual</title>
|
||||
<link rel="canonical" href="/" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "25.6.0",
|
||||
"version": "25.7.0",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
@@ -16,6 +16,7 @@
|
||||
"@swc/helpers": "^0.5.17",
|
||||
"@swc/plugin-react-remove-properties": "^1.5.121",
|
||||
"@testing-library/dom": "10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/debounce": "^1.2.4",
|
||||
@@ -25,19 +26,19 @@
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@types/react-grid-layout": "^1",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/webpack-bundle-analyzer": "^4.7.0",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^1.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.6.0",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"chokidar": "^3.6.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"debounce": "^1.2.1",
|
||||
"downshift": "7.6.2",
|
||||
"focus-visible": "^4.1.5",
|
||||
"i18next": "^23.16.8",
|
||||
"i18next": "^25.2.1",
|
||||
"i18next-parser": "^9.3.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"inter-ui": "^3.19.3",
|
||||
@@ -59,11 +60,11 @@
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-grid-layout": "^1.5.1",
|
||||
"react-hotkeys-hook": "^4.6.2",
|
||||
"react-i18next": "^14.1.3",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "6.30.0",
|
||||
"react-router": "7.6.2",
|
||||
"react-simple-pull-to-refresh": "^1.3.3",
|
||||
"react-spring": "^10.0.0",
|
||||
"react-stately": "^3.37.0",
|
||||
@@ -77,11 +78,11 @@
|
||||
"swc-loader": "^0.2.6",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^9.0.1",
|
||||
"uuid": "^11.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^3.1.3",
|
||||
"vitest": "^3.2.4",
|
||||
"webpack": "^5.99.8",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"xml2js": "^0.6.2"
|
||||
|
||||
@@ -233,25 +233,41 @@ export const syncAccounts = createAppAsyncThunk(
|
||||
return false;
|
||||
}
|
||||
|
||||
const batchSync = !id;
|
||||
|
||||
// Build an array of IDs for accounts to sync.. if no `id` provided
|
||||
// then we assume that all accounts should be synced
|
||||
const queriesState = getState().queries;
|
||||
let accountIdsToSync = !batchSync
|
||||
? [id]
|
||||
: queriesState.accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.offbudget === b.offbudget
|
||||
? a.sort_order - b.sort_order
|
||||
: a.offbudget - b.offbudget,
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
|
||||
const { setAccountsSyncing } = accountsSlice.actions;
|
||||
|
||||
if (id === 'uncategorized') {
|
||||
// Sync no accounts
|
||||
dispatch(setAccountsSyncing({ ids: [] }));
|
||||
return false;
|
||||
}
|
||||
|
||||
const queriesState = getState().queries;
|
||||
let accountIdsToSync: string[];
|
||||
if (id === 'offbudget' || id === 'onbudget') {
|
||||
const targetOffbudget = id === 'offbudget' ? 1 : 0;
|
||||
accountIdsToSync = queriesState.accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone, offbudget }) =>
|
||||
!!bank && !closed && !tombstone && offbudget === targetOffbudget,
|
||||
)
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
.map(({ id }) => id);
|
||||
} else if (id) {
|
||||
accountIdsToSync = [id];
|
||||
} else {
|
||||
// Default: all accounts
|
||||
accountIdsToSync = queriesState.accounts
|
||||
.filter(
|
||||
({ bank, closed, tombstone }) => !!bank && !closed && !tombstone,
|
||||
)
|
||||
.sort((a, b) =>
|
||||
a.offbudget === b.offbudget
|
||||
? a.sort_order - b.sort_order
|
||||
: a.offbudget - b.offbudget,
|
||||
)
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
|
||||
// TODO: Force cast to AccountEntity.
|
||||
@@ -260,7 +276,9 @@ export const syncAccounts = createAppAsyncThunk(
|
||||
'accounts-get',
|
||||
)) as unknown as AccountEntity[];
|
||||
const simpleFinAccounts = accountsData.filter(
|
||||
a => a.account_sync_source === 'simpleFin',
|
||||
a =>
|
||||
a.account_sync_source === 'simpleFin' &&
|
||||
accountIdsToSync.includes(a.id),
|
||||
);
|
||||
|
||||
let isSyncSuccess = false;
|
||||
@@ -268,7 +286,7 @@ export const syncAccounts = createAppAsyncThunk(
|
||||
const matchedTransactions: Array<TransactionEntity['id']> = [];
|
||||
const updatedAccounts: Array<AccountEntity['id']> = [];
|
||||
|
||||
if (batchSync && simpleFinAccounts.length > 0) {
|
||||
if (simpleFinAccounts.length > 0) {
|
||||
console.log('Using SimpleFin batch sync');
|
||||
|
||||
const res = await send('simplefin-batch-sync', {
|
||||
|
||||
@@ -121,7 +121,9 @@ export const syncAndDownload = createAppAsyncThunk(
|
||||
return { error: syncState.error };
|
||||
}
|
||||
|
||||
const hasDownloaded = await dispatch(syncAccounts({ id: accountId }));
|
||||
const hasDownloaded = await dispatch(
|
||||
syncAccounts({ id: accountId }),
|
||||
).unwrap();
|
||||
|
||||
if (hasDownloaded) {
|
||||
// Sync again afterwards if new transactions were created
|
||||
|
||||
@@ -22,7 +22,7 @@ const ACTUAL_VERSION = Platform.isPlaywright
|
||||
: packageJson.version;
|
||||
|
||||
// *** Start the backend ***
|
||||
let worker;
|
||||
let worker = null;
|
||||
|
||||
function createBackendWorker() {
|
||||
worker = new Worker(backendWorkerUrl);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'react-error-boundary';
|
||||
import { HotkeysProvider } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { View } from '@actual-app/components/view';
|
||||
@@ -71,7 +71,7 @@ function AppInner() {
|
||||
};
|
||||
|
||||
async function init() {
|
||||
const socketName = await maybeUpdate(() =>
|
||||
const serverSocket = await maybeUpdate(() =>
|
||||
global.Actual.getServerSocket(),
|
||||
);
|
||||
|
||||
@@ -82,7 +82,7 @@ function AppInner() {
|
||||
),
|
||||
}),
|
||||
);
|
||||
await initConnection(socketName);
|
||||
await initConnection(serverSocket);
|
||||
|
||||
// Load any global prefs
|
||||
dispatch(
|
||||
|
||||
278
packages/desktop-client/src/components/CommandBar.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
type ComponentType,
|
||||
type SVGProps,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
SvgCog,
|
||||
SvgPiggyBank,
|
||||
SvgReports,
|
||||
SvgStoreFront,
|
||||
SvgTuning,
|
||||
SvgWallet,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import {
|
||||
SvgCalendar3,
|
||||
SvgNotesPaperText,
|
||||
} from '@actual-app/components/icons/v2';
|
||||
import { css } from '@emotion/css';
|
||||
import { Command } from 'cmdk';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
|
||||
import { useModalState } from '@desktop-client/hooks/useModalState';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useReports } from '@desktop-client/hooks/useReports';
|
||||
|
||||
type SearchableItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>;
|
||||
};
|
||||
|
||||
type SearchSection = {
|
||||
key: string;
|
||||
heading: string;
|
||||
items: Readonly<SearchableItem[]>;
|
||||
onSelect: (item: Pick<SearchableItem, 'id'>) => void;
|
||||
};
|
||||
|
||||
export function CommandBar() {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const [budgetName] = useMetadataPref('budgetName');
|
||||
const { modalStack } = useModalState();
|
||||
|
||||
const navigationItems = useMemo(
|
||||
() => [
|
||||
{ id: 'budget', name: t('Budget'), path: '/budget', Icon: SvgWallet },
|
||||
{
|
||||
id: 'reports-nav',
|
||||
name: t('Reports'),
|
||||
path: '/reports',
|
||||
Icon: SvgReports,
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
name: t('Schedules'),
|
||||
path: '/schedules',
|
||||
Icon: SvgCalendar3,
|
||||
},
|
||||
{ id: 'payees', name: t('Payees'), path: '/payees', Icon: SvgStoreFront },
|
||||
{ id: 'rules', name: t('Rules'), path: '/rules', Icon: SvgTuning },
|
||||
{ id: 'settings', name: t('Settings'), path: '/settings', Icon: SvgCog },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset search when closing
|
||||
if (!open) setSearch('');
|
||||
}, [open]);
|
||||
|
||||
const allAccounts = useAccounts();
|
||||
const { data: customReports } = useReports();
|
||||
|
||||
const accounts = allAccounts.filter(acc => !acc.closed);
|
||||
|
||||
const openEventListener = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
// Do not open CommandBar if a modal is already open
|
||||
if (modalStack.length > 0) return;
|
||||
setOpen(true);
|
||||
}
|
||||
},
|
||||
[modalStack.length],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', openEventListener);
|
||||
return () => document.removeEventListener('keydown', openEventListener);
|
||||
}, [openEventListener]);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(path: string) => {
|
||||
setOpen(false);
|
||||
navigate(path);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const sections: SearchSection[] = [
|
||||
{
|
||||
key: 'navigation',
|
||||
heading: t('Navigation'),
|
||||
items: navigationItems.map(({ id, name, Icon }) => ({
|
||||
id,
|
||||
name,
|
||||
Icon,
|
||||
})),
|
||||
onSelect: ({ id }) => {
|
||||
const item = navigationItems.find(item => item.id === id);
|
||||
if (!!item) handleNavigate(item.path);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'accounts',
|
||||
heading: t('Accounts'),
|
||||
items: accounts.map(account => ({
|
||||
...account,
|
||||
Icon: SvgPiggyBank,
|
||||
})),
|
||||
onSelect: ({ id }) => handleNavigate(`/accounts/${id}`),
|
||||
},
|
||||
{
|
||||
key: 'reports-custom',
|
||||
heading: t('Custom Reports'),
|
||||
items: customReports.map(report => ({
|
||||
...report,
|
||||
Icon: SvgNotesPaperText,
|
||||
})),
|
||||
onSelect: ({ id }) => handleNavigate(`/reports/custom/${id}`),
|
||||
},
|
||||
];
|
||||
|
||||
const searchLower = search.toLowerCase();
|
||||
const filteredSections = sections.map(section => ({
|
||||
...section,
|
||||
items: section.items.filter(item =>
|
||||
item.name.toLowerCase().includes(searchLower),
|
||||
),
|
||||
}));
|
||||
const hasResults = filteredSections.some(section => !!section.items.length);
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
vimBindings
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label={t('Command Bar')}
|
||||
aria-label={t('Command Bar')}
|
||||
shouldFilter={false}
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: '30%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -30%)',
|
||||
width: '90%',
|
||||
maxWidth: '600px',
|
||||
backgroundColor: 'var(--color-modalBackground)',
|
||||
border: '1px solid var(--color-modalBorder)',
|
||||
color: 'var(--color-pageText)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
|
||||
overflow: 'hidden',
|
||||
zIndex: 3001,
|
||||
})}
|
||||
>
|
||||
<Command.Input
|
||||
autoFocus
|
||||
placeholder={t('Search {{budgetName}}...', { budgetName })}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
className={css({
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
fontSize: '1rem',
|
||||
border: 'none',
|
||||
borderBottom: '1px solid var(--color-tableBorderSeparator)',
|
||||
backgroundColor: 'transparent',
|
||||
color: 'var(--color-pageText)',
|
||||
outline: 'none',
|
||||
'&::placeholder': {
|
||||
color: 'var(--color-pageTextSubdued)',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Command.List
|
||||
className={css({
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
padding: '8px 0',
|
||||
// Hide the scrollbar
|
||||
scrollbarWidth: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
// Ensure content is still scrollable
|
||||
msOverflowStyle: 'none',
|
||||
})}
|
||||
>
|
||||
{filteredSections.map(
|
||||
section =>
|
||||
!!section.items.length && (
|
||||
<Command.Group
|
||||
key={section.key}
|
||||
heading={section.heading}
|
||||
className={css({
|
||||
padding: '0 8px',
|
||||
'& [cmdk-group-heading]': {
|
||||
padding: '8px 8px 4px',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
color: 'var(--color-pageTextSubdued)',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{section.items.map(({ id, name, Icon }) => (
|
||||
<Command.Item
|
||||
key={id}
|
||||
onSelect={() => section.onSelect({ id })}
|
||||
value={name}
|
||||
className={css({
|
||||
padding: '8px 16px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
borderRadius: '4px',
|
||||
margin: '0',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
// Avoid showing mouse hover styles when using keyboard navigation
|
||||
'[data-cmdk-list]:not([data-cmdk-list-nav-active]) &:hover':
|
||||
{
|
||||
backgroundColor:
|
||||
'var(--color-menuItemBackgroundHover)',
|
||||
color: 'var(--color-menuItemTextHover)',
|
||||
},
|
||||
// eslint-disable-next-line rulesdir/typography
|
||||
"&[data-selected='true']": {
|
||||
backgroundColor: 'var(--color-menuItemBackgroundHover)',
|
||||
color: 'var(--color-menuItemTextHover)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<Icon width={16} height={16} />
|
||||
{name}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
),
|
||||
)}
|
||||
|
||||
{!hasResults && (
|
||||
<Command.Empty
|
||||
className={css({
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
fontSize: '0.9rem',
|
||||
color: 'var(--color-pageTextSubdued)',
|
||||
})}
|
||||
>
|
||||
<Trans>No results found</Trans>
|
||||
</Command.Empty>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,7 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { type ReactElement, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Route,
|
||||
Routes,
|
||||
Navigate,
|
||||
useLocation,
|
||||
useHref,
|
||||
} from 'react-router-dom';
|
||||
import { Route, Routes, Navigate, useLocation, useHref } from 'react-router';
|
||||
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -18,6 +12,7 @@ import * as undo from 'loot-core/platform/client/undo';
|
||||
import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
|
||||
import { BankSync } from './banksync';
|
||||
import { BankSyncStatus } from './BankSyncStatus';
|
||||
import { CommandBar } from './CommandBar';
|
||||
import { GlobalKeys } from './GlobalKeys';
|
||||
import { ManageRulesPage } from './ManageRulesPage';
|
||||
import { Category } from './mobile/budget/Category';
|
||||
@@ -146,10 +141,16 @@ export function FinancesApp() {
|
||||
notification: {
|
||||
type: 'message',
|
||||
title: t('A new version of Actual is available!'),
|
||||
message: t(
|
||||
'Version {{latestVersion}} of Actual was recently released.',
|
||||
{ latestVersion },
|
||||
),
|
||||
message:
|
||||
(process.env.REACT_APP_IS_PIKAPODS ?? '').toLowerCase() ===
|
||||
'true'
|
||||
? t(
|
||||
'A new version of Actual is available! Your Pikapods instance will be automatically updated in the next few days - no action needed.',
|
||||
)
|
||||
: t(
|
||||
'Version {{latestVersion}} of Actual was recently released.',
|
||||
{ latestVersion },
|
||||
),
|
||||
sticky: true,
|
||||
id: 'update-notification',
|
||||
button: {
|
||||
@@ -176,7 +177,7 @@ export function FinancesApp() {
|
||||
<View style={{ height: '100%' }}>
|
||||
<RouterBehaviors />
|
||||
<GlobalKeys />
|
||||
|
||||
<CommandBar />
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { forwardRef, useRef } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgHelp } from '@actual-app/components/icons/v2';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useRef, type CSSProperties } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
type SetStateAction,
|
||||
type Dispatch,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
@@ -114,6 +114,8 @@ export function ManageRules({
|
||||
payeeId,
|
||||
setLoading = () => {},
|
||||
}: ManageRulesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -210,7 +212,7 @@ export function ManageRules({
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
alert(
|
||||
t('Some rules were not deleted because they are linked to schedules'),
|
||||
t('Some rules were not deleted because they are linked to schedules.'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,7 +286,6 @@ export function ManageRules({
|
||||
const onHover = useCallback(id => {
|
||||
setHoveredRule(id);
|
||||
}, []);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
@@ -306,13 +307,15 @@ export function ManageRules({
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{t('Rules are always run in the order that you see them.')}{' '}
|
||||
<Trans>
|
||||
Rules are always run in the order that you see them.
|
||||
</Trans>{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/budgeting/rules/"
|
||||
linkColor="muted"
|
||||
>
|
||||
{t('Learn more')}
|
||||
<Trans>Learn more</Trans>
|
||||
</Link>
|
||||
</Text>
|
||||
</View>
|
||||
@@ -351,11 +354,13 @@ export function ManageRules({
|
||||
<Stack direction="row" align="center" justify="flex-end" spacing={2}>
|
||||
{selectedInst.items.size > 0 && (
|
||||
<Button onPress={onDeleteSelected}>
|
||||
Delete {selectedInst.items.size} rules
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
{t('Create new rule')}
|
||||
<Trans>Create new rule</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
</View>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { send } from 'loot-core/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
@@ -31,6 +31,7 @@ import { EnvelopeBalanceMenuModal } from './modals/EnvelopeBalanceMenuModal';
|
||||
import { EnvelopeBudgetMenuModal } from './modals/EnvelopeBudgetMenuModal';
|
||||
import { EnvelopeBudgetMonthMenuModal } from './modals/EnvelopeBudgetMonthMenuModal';
|
||||
import { EnvelopeBudgetSummaryModal } from './modals/EnvelopeBudgetSummaryModal';
|
||||
import { EnvelopeIncomeBalanceMenuModal } from './modals/EnvelopeIncomeBalanceMenuModal';
|
||||
import { EnvelopeToBudgetMenuModal } from './modals/EnvelopeToBudgetMenuModal';
|
||||
import { FixEncryptionKeyModal } from './modals/FixEncryptionKeyModal';
|
||||
import { GoalTemplateModal } from './modals/GoalTemplateModal';
|
||||
@@ -73,10 +74,10 @@ import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
||||
import { ScheduleDetails } from './schedules/ScheduleDetails';
|
||||
import { ScheduleLink } from './schedules/ScheduleLink';
|
||||
import { UpcomingLength } from './schedules/UpcomingLength';
|
||||
import { NamespaceContext } from './spreadsheet/NamespaceContext';
|
||||
|
||||
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
|
||||
import { useModalState } from '@desktop-client/hooks/useModalState';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { closeModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
@@ -203,12 +204,12 @@ export function Modals() {
|
||||
|
||||
case 'envelope-budget-summary':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBudgetSummaryModal key={key} {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'tracking-budget-summary':
|
||||
@@ -240,22 +241,22 @@ export function Modals() {
|
||||
|
||||
case 'envelope-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBudgetMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'tracking-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<TrackingBudgetMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'category-group-menu':
|
||||
@@ -266,42 +267,52 @@ export function Modals() {
|
||||
|
||||
case 'envelope-balance-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBalanceMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'envelope-income-balance-menu':
|
||||
return (
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeIncomeBalanceMenuModal {...modal.options} />
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'envelope-summary-to-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeToBudgetMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'hold-buffer':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<HoldBufferModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'tracking-balance-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<TrackingBalanceMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'transfer':
|
||||
@@ -318,22 +329,22 @@ export function Modals() {
|
||||
|
||||
case 'envelope-budget-month-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBudgetMonthMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'tracking-budget-month-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
<SheetNameProvider
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
name={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<TrackingBudgetMonthMenuModal {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
|
||||
case 'budget-file-selection':
|
||||
|
||||
@@ -22,6 +22,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { Link } from './common/Link';
|
||||
import { MODAL_Z_INDEX } from './common/Modal';
|
||||
|
||||
import {
|
||||
removeNotification,
|
||||
@@ -313,7 +314,7 @@ export function Notifications({ style }: { style?: CSSProperties }) {
|
||||
top: notificationInset?.top,
|
||||
right: notificationInset?.right || 13,
|
||||
left: notificationInset?.left || (isNarrowWidth ? 13 : undefined),
|
||||
zIndex: 10000,
|
||||
zIndex: MODAL_Z_INDEX - 1,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Routes, Route, useLocation } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Routes, Route, useLocation } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
|
||||
@@ -34,19 +34,19 @@ import { HelpMenu } from './HelpMenu';
|
||||
import { LoggedInUser } from './LoggedInUser';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import { useSidebar } from './sidebar/SidebarProvider';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
|
||||
import { sync } from '@desktop-client/app/appSlice';
|
||||
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
|
||||
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import * as queries from '@desktop-client/queries/queries';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import * as bindings from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
function UncategorizedButton() {
|
||||
const count: number | null = useSheetValue(queries.uncategorizedCount());
|
||||
const count: number | null = useSheetValue(bindings.uncategorizedCount());
|
||||
if (count === null || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function UncategorizedButton() {
|
||||
color: theme.errorText,
|
||||
}}
|
||||
>
|
||||
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
|
||||
<Trans count={count}>{{ count }} uncategorized transactions</Trans>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ type PrivacyButtonProps = {
|
||||
};
|
||||
|
||||
function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isPrivacyEnabledPref, setPrivacyEnabledPref] =
|
||||
useSyncedPref('isPrivacyEnabled');
|
||||
const isPrivacyEnabled = String(isPrivacyEnabledPref) === 'true';
|
||||
@@ -91,7 +92,9 @@ function PrivacyButton({ style }: PrivacyButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
aria-label={`${isPrivacyEnabled ? 'Disable' : 'Enable'} privacy mode`}
|
||||
aria-label={
|
||||
isPrivacyEnabled ? t('Disable privacy mode') : t('Enable privacy mode')
|
||||
}
|
||||
onPress={() => setPrivacyEnabledPref(String(!isPrivacyEnabled))}
|
||||
style={style}
|
||||
>
|
||||
@@ -242,10 +245,10 @@ function SyncButton({ style, isMobile = false }: SyncButtonProps) {
|
||||
)}
|
||||
<Text style={isMobile ? { ...mobileTextStyle } : { marginLeft: 3 }}>
|
||||
{syncState === 'disabled'
|
||||
? 'Disabled'
|
||||
? t('Disabled')
|
||||
: syncState === 'offline'
|
||||
? 'Offline'
|
||||
: 'Sync'}
|
||||
? t('Offline')
|
||||
: t('Sync')}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
@@ -328,7 +331,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
height={10}
|
||||
style={{ marginRight: 5, color: 'currentColor' }}
|
||||
/>{' '}
|
||||
{t('Back')}
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgClose } from '@actual-app/components/icons/v1';
|
||||
@@ -45,7 +45,7 @@ export function UpdateNotification() {
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ marginRight: 10, fontWeight: 700 }}>
|
||||
<Text>
|
||||
{t('App updated to {{version}}', { version: updateInfo.version })}
|
||||
<Trans>App updated to {{ version: updateInfo.version }}</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }} />
|
||||
@@ -59,7 +59,7 @@ export function UpdateNotification() {
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{t('Restart')}
|
||||
<Trans>Restart</Trans>
|
||||
</Link>{' '}
|
||||
(
|
||||
<Link
|
||||
@@ -74,7 +74,7 @@ export function UpdateNotification() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('notes')}
|
||||
<Trans>notes</Trans>
|
||||
</Link>
|
||||
)
|
||||
<Button
|
||||
|
||||
@@ -7,7 +7,7 @@ import React, {
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Navigate, useParams, useLocation } from 'react-router-dom';
|
||||
import { Navigate, useParams, useLocation } from 'react-router';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
@@ -73,12 +73,12 @@ import {
|
||||
replaceModal,
|
||||
} from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import * as queries from '@desktop-client/queries';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import {
|
||||
pagedQuery,
|
||||
type PagedQuery,
|
||||
} from '@desktop-client/queries/pagedQuery';
|
||||
import * as queries from '@desktop-client/queries/queries';
|
||||
import {
|
||||
createPayee,
|
||||
initiallyLoadPayees,
|
||||
@@ -583,7 +583,7 @@ class AccountInternal extends PureComponent<
|
||||
const account = this.props.accounts.find(acct => acct.id === accountId);
|
||||
|
||||
await this.props.dispatch(
|
||||
syncAndDownload({ accountId: account ? account.id : undefined }),
|
||||
syncAndDownload({ accountId: account ? account.id : accountId }),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1713,6 +1713,14 @@ class AccountInternal extends PureComponent<
|
||||
|
||||
const balanceQuery = this.getBalanceQuery(accountId);
|
||||
|
||||
const selectAllFilter = (item: TransactionEntity): boolean => {
|
||||
if (item.is_parent) {
|
||||
const children = transactions.filter(t => t.parent_id === item.id);
|
||||
return children.every(t => selectAllFilter(t));
|
||||
}
|
||||
return !item._unmatched;
|
||||
};
|
||||
|
||||
return (
|
||||
<AllTransactions
|
||||
account={account}
|
||||
@@ -1727,7 +1735,7 @@ class AccountInternal extends PureComponent<
|
||||
items={allTransactions}
|
||||
fetchAllIds={this.fetchAllIds}
|
||||
registerDispatch={dispatch => (this.dispatchSelected = dispatch)}
|
||||
selectAllFilter={item => !item._unmatched && !item.is_parent}
|
||||
selectAllFilter={selectAllFilter}
|
||||
>
|
||||
<View style={styles.page}>
|
||||
<AccountHeader
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgExclamationOutline } from '@actual-app/components/icons/v1';
|
||||
|
||||
@@ -14,15 +14,15 @@ import { isPreviewId } from 'loot-core/shared/transactions';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
|
||||
import { type Binding } from '@desktop-client/components/spreadsheet';
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import { useCachedSchedules } from '@desktop-client/hooks/useCachedSchedules';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useSelectedItems } from '@desktop-client/hooks/useSelected';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { type Binding } from '@desktop-client/spreadsheet';
|
||||
|
||||
type DetailedBalanceProps = {
|
||||
name: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type FormEvent, useState } from 'react';
|
||||
import React, { useEffect, type FormEvent, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
@@ -17,10 +17,10 @@ import { currencyToInteger, tsToRelativeTime } from 'loot-core/shared/util';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { type TransObjectLiteral } from 'loot-core/types/util';
|
||||
|
||||
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import * as queries from '@desktop-client/queries/queries';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import * as bindings from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type ReconcilingMessageProps = {
|
||||
balanceQuery: { name: `balance-query-${string}`; query: Query };
|
||||
@@ -128,15 +128,24 @@ export function ReconcileMenu({
|
||||
onReconcile,
|
||||
onClose,
|
||||
}: ReconcileMenuProps) {
|
||||
const balanceQuery = queries.accountBalance(account.id);
|
||||
const balanceQuery = bindings.accountBalance(account.id);
|
||||
const clearedBalance = useSheetValue<'account', `balance-${string}-cleared`>({
|
||||
name: (balanceQuery.name + '-cleared') as `balance-${string}-cleared`,
|
||||
value: null,
|
||||
query: balanceQuery.query.filter({ cleared: true }),
|
||||
});
|
||||
const lastSyncedBalance = account.balance_current;
|
||||
const format = useFormat();
|
||||
const locale = useLocale();
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useState<string | null>();
|
||||
// useEffect is needed here. clearedBalance does not work as a default value for inputValue and
|
||||
// to use a button to update inputValue we can't use defaultValue in the input form below
|
||||
useEffect(() => {
|
||||
if (clearedBalance != null) {
|
||||
setInputValue(format(clearedBalance, 'financial'));
|
||||
}
|
||||
}, [clearedBalance, format]);
|
||||
|
||||
function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
@@ -161,14 +170,28 @@ export function ReconcileMenu({
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={format(clearedBalance, 'financial')}
|
||||
onChangeValue={setInputValue}
|
||||
style={{ margin: '7px 0' }}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
value={inputValue ?? ''}
|
||||
onChangeValue={setInputValue}
|
||||
style={{ margin: '7px 0' }}
|
||||
/>
|
||||
</InitialFocus>
|
||||
{lastSyncedBalance != null && (
|
||||
<View>
|
||||
<Text>
|
||||
<Trans>Last Balance from Bank: </Trans>
|
||||
{format(lastSyncedBalance, 'financial')}
|
||||
</Text>
|
||||
<Button
|
||||
onPress={() =>
|
||||
setInputValue(format(lastSyncedBalance, 'financial'))
|
||||
}
|
||||
style={{ marginBottom: 7 }}
|
||||
>
|
||||
<Trans>Use last synced total</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
<Text style={{ color: theme.pageTextSubdued, paddingBottom: 6 }}>
|
||||
{account?.last_reconciled
|
||||
|
||||
@@ -176,7 +176,7 @@ function UserAccessContent({
|
||||
</Trans>{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/budgeting/users-access/"
|
||||
to="https://actualbudget.org/docs/config/multi-user#user-access-management"
|
||||
linkColor="muted"
|
||||
>
|
||||
<Trans>Learn more</Trans>
|
||||
@@ -274,13 +274,14 @@ type LockToggleProps = {
|
||||
function LockToggle({ style, onToggleSave }: LockToggleProps) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
onHoverStart={() => setHover(true)}
|
||||
onHoverEnd={() => setHover(false)}
|
||||
variant="primary"
|
||||
aria-label="Menu"
|
||||
aria-label={t('Menu')}
|
||||
onPress={() =>
|
||||
dispatch(
|
||||
pushModal({
|
||||
|
||||
@@ -286,7 +286,7 @@ function UserDirectoryContent({
|
||||
</Trans>{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/budgeting/users/"
|
||||
to="https://actualbudget.org/docs/config/multi-user/"
|
||||
linkColor="muted"
|
||||
>
|
||||
<Trans>Learn more</Trans>
|
||||
|
||||
@@ -33,13 +33,13 @@ import { ItemHeader } from './ItemHeader';
|
||||
|
||||
import { useEnvelopeSheetValue } from '@desktop-client/components/budget/envelope/EnvelopeBudgetComponents';
|
||||
import { makeAmountFullStyle } from '@desktop-client/components/budget/util';
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import {
|
||||
trackingBudget,
|
||||
envelopeBudget,
|
||||
} from '@desktop-client/queries/queries';
|
||||
} from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type CategoryAutocompleteItem = Omit<CategoryEntity, 'group'> & {
|
||||
group?: CategoryGroupEntity;
|
||||
|
||||
@@ -19,14 +19,14 @@ import { type TransObjectLiteral } from 'loot-core/types/util';
|
||||
|
||||
import { makeBalanceAmountStyle } from './util';
|
||||
|
||||
import { type Binding } from '@desktop-client/components/spreadsheet';
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { type Binding } from '@desktop-client/spreadsheet';
|
||||
|
||||
type CarryoverIndicatorProps = {
|
||||
style?: CSSProperties;
|
||||
|
||||
@@ -13,7 +13,7 @@ import * as monthUtils from 'loot-core/shared/months';
|
||||
|
||||
import { MonthsContext } from './MonthsContext';
|
||||
|
||||
import { NamespaceContext } from '@desktop-client/components/spreadsheet/NamespaceContext';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
|
||||
type RenderMonthsProps = {
|
||||
component?: ComponentType<{ month: string; editing: boolean }>;
|
||||
@@ -34,10 +34,7 @@ export function RenderMonths({
|
||||
const editing = editingMonth === month;
|
||||
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={index}
|
||||
value={monthUtils.sheetForMonth(month)}
|
||||
>
|
||||
<SheetNameProvider key={index} name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -47,7 +44,7 @@ export function RenderMonths({
|
||||
>
|
||||
<Component month={month} editing={editing} {...args} />
|
||||
</View>
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
);
|
||||
}) as unknown as JSX.Element;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,16 @@ import { Menu } from '@actual-app/components/menu';
|
||||
|
||||
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
|
||||
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type BalanceMenuProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof Menu>,
|
||||
'onMenuSelect' | 'items'
|
||||
> & {
|
||||
categoryId: string;
|
||||
onTransfer: () => void;
|
||||
onCarryover: (carryOver: boolean) => void;
|
||||
onCover: () => void;
|
||||
onTransfer?: () => void;
|
||||
onCarryover?: (carryOver: boolean) => void;
|
||||
onCover?: () => void;
|
||||
};
|
||||
|
||||
export function BalanceMenu({
|
||||
|
||||
@@ -5,7 +5,7 @@ import { CoverMenu } from './CoverMenu';
|
||||
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
|
||||
import { TransferMenu } from './TransferMenu';
|
||||
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type BalanceMovementMenuProps = {
|
||||
categoryId: string;
|
||||
|
||||
@@ -29,19 +29,14 @@ import {
|
||||
|
||||
import { BalanceMovementMenu } from './BalanceMovementMenu';
|
||||
import { BudgetMenu } from './BudgetMenu';
|
||||
import { IncomeMenu } from './IncomeMenu';
|
||||
|
||||
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
|
||||
import { makeAmountGrey } from '@desktop-client/components/budget/util';
|
||||
import {
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '@desktop-client/components/spreadsheet';
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
} from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useSheetName } from '@desktop-client/components/spreadsheet/useSheetName';
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import {
|
||||
Row,
|
||||
Field,
|
||||
@@ -51,8 +46,11 @@ import {
|
||||
import { useCategoryScheduleGoalTemplateIndicator } from '@desktop-client/hooks/useCategoryScheduleGoalTemplateIndicator';
|
||||
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSheetName } from '@desktop-client/hooks/useSheetName';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { type Binding, type SheetFields } from '@desktop-client/spreadsheet';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
export function useEnvelopeSheetName<
|
||||
FieldName extends SheetFields<'envelope-budget'>,
|
||||
@@ -334,7 +332,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget set to last month’s budget.`),
|
||||
message: t(`Budget set to last month‘s budget.`),
|
||||
});
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
@@ -482,7 +480,7 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
handleBalanceContextMenu(e);
|
||||
// We need to calculate differently from the hook ue to being aligned to the right
|
||||
// We need to calculate differently from the hook due to being aligned to the right
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
resetBalancePosition(
|
||||
e.clientX - rect.right + 200 - 8,
|
||||
@@ -553,20 +551,32 @@ type IncomeCategoryMonthProps = {
|
||||
isLast: boolean;
|
||||
month: string;
|
||||
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
|
||||
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
||||
};
|
||||
export function IncomeCategoryMonth({
|
||||
category,
|
||||
isLast,
|
||||
month,
|
||||
onShowActivity,
|
||||
onBudgetAction,
|
||||
}: IncomeCategoryMonthProps) {
|
||||
const incomeMenuTriggerRef = useRef(null);
|
||||
const {
|
||||
setMenuOpen: setIncomeMenuOpen,
|
||||
menuOpen: incomeMenuOpen,
|
||||
handleContextMenu: handleIncomeContextMenu,
|
||||
resetPosition: resetIncomePosition,
|
||||
position: incomePosition,
|
||||
} = useContextMenu();
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
<Field
|
||||
name="received"
|
||||
width="flex"
|
||||
truncate={false}
|
||||
ref={incomeMenuTriggerRef}
|
||||
style={{
|
||||
paddingRight: styles.monthRightPadding,
|
||||
textAlign: 'right',
|
||||
...(isLast && { borderBottomWidth: 0 }),
|
||||
backgroundColor: monthUtils.isCurrentMonth(month)
|
||||
@@ -574,15 +584,58 @@ export function IncomeCategoryMonth({
|
||||
: theme.budgetOtherMonth,
|
||||
}}
|
||||
>
|
||||
<span onClick={() => onShowActivity(category.id, month)}>
|
||||
<BalanceWithCarryover
|
||||
carryover={envelopeBudget.catCarryover(category.id)}
|
||||
balance={envelopeBudget.catSumAmount(category.id)}
|
||||
goal={envelopeBudget.catGoal(category.id)}
|
||||
budgeted={envelopeBudget.catBudgeted(category.id)}
|
||||
longGoal={envelopeBudget.catLongGoal(category.id)}
|
||||
/>
|
||||
</span>
|
||||
<View
|
||||
name="received"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={() => {
|
||||
resetIncomePosition(-6, -4);
|
||||
setIncomeMenuOpen(true);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
handleIncomeContextMenu(e);
|
||||
// We need to calculate differently from the hook due to being aligned to the right
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
resetIncomePosition(
|
||||
e.clientX - rect.right + 200 - 8,
|
||||
e.clientY - rect.bottom - 8,
|
||||
);
|
||||
}}
|
||||
style={{ paddingRight: styles.monthRightPadding }}
|
||||
>
|
||||
<BalanceWithCarryover
|
||||
carryover={envelopeBudget.catCarryover(category.id)}
|
||||
balance={envelopeBudget.catSumAmount(category.id)}
|
||||
goal={envelopeBudget.catGoal(category.id)}
|
||||
budgeted={envelopeBudget.catBudgeted(category.id)}
|
||||
longGoal={envelopeBudget.catLongGoal(category.id)}
|
||||
/>
|
||||
</span>
|
||||
<Popover
|
||||
triggerRef={incomeMenuTriggerRef}
|
||||
placement="bottom end"
|
||||
isOpen={incomeMenuOpen}
|
||||
onOpenChange={() => setIncomeMenuOpen(false)}
|
||||
style={{ margin: 1 }}
|
||||
isNonModal
|
||||
{...incomePosition}
|
||||
>
|
||||
<IncomeMenu
|
||||
categoryId={category.id}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onClose={() => setIncomeMenuOpen(false)}
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
</Field>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { evalArithmetic } from 'loot-core/shared/arithmetic';
|
||||
import { integerToCurrency, amountToInteger } from 'loot-core/shared/util';
|
||||
|
||||
import { useSheetValue } from '@desktop-client/components/spreadsheet/useSheetValue';
|
||||
import { useSheetValue } from '@desktop-client/hooks/useSheetValue';
|
||||
|
||||
type HoldMenuProps = {
|
||||
onSubmit: (amount: number) => void;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
|
||||
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type IncomeMenuProps = {
|
||||
categoryId: string;
|
||||
month: string;
|
||||
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
||||
onShowActivity: (id: CategoryEntity['id'], month: string) => void;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export function IncomeMenu({
|
||||
categoryId,
|
||||
month,
|
||||
onBudgetAction,
|
||||
onShowActivity,
|
||||
onClose = () => {},
|
||||
}: IncomeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const carryover = useEnvelopeSheetValue(
|
||||
envelopeBudget.catCarryover(categoryId),
|
||||
);
|
||||
|
||||
return (
|
||||
<span>
|
||||
<Menu
|
||||
onMenuSelect={name => {
|
||||
switch (name) {
|
||||
case 'view':
|
||||
onShowActivity(categoryId, month);
|
||||
break;
|
||||
case 'carryover':
|
||||
if (!carryover) onBudgetAction(month, 'reset-hold');
|
||||
onBudgetAction(month, 'carryover', {
|
||||
category: categoryId,
|
||||
flag: !carryover,
|
||||
});
|
||||
onClose();
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu option: ${name}`);
|
||||
}
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
name: 'carryover',
|
||||
text: carryover ? t('Disable auto hold') : t('Enable auto hold'),
|
||||
},
|
||||
{
|
||||
name: 'view',
|
||||
text: t('View transactions'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -21,8 +21,8 @@ import { TotalsList } from './TotalsList';
|
||||
|
||||
import { useEnvelopeBudget } from '@desktop-client/components/budget/envelope/EnvelopeBudgetContext';
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import { NamespaceContext } from '@desktop-client/components/spreadsheet/NamespaceContext';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { SheetNameProvider } from '@desktop-client/hooks/useSheetName';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
|
||||
type BudgetSummaryProps = {
|
||||
@@ -88,7 +88,7 @@ export const BudgetSummary = memo(({ month }: BudgetSummaryProps) => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<NamespaceContext.Provider value={monthUtils.sheetForMonth(month)}>
|
||||
<SheetNameProvider name={monthUtils.sheetForMonth(month)}>
|
||||
<View
|
||||
style={{
|
||||
padding: '0 13px',
|
||||
@@ -287,7 +287,7 @@ export const BudgetSummary = memo(({ month }: BudgetSummaryProps) => {
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</NamespaceContext.Provider>
|
||||
</SheetNameProvider>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useEnvelopeSheetValue } from '@desktop-client/components/budget/envelop
|
||||
import { HoldMenu } from '@desktop-client/components/budget/envelope/HoldMenu';
|
||||
import { TransferMenu } from '@desktop-client/components/budget/envelope/TransferMenu';
|
||||
import { useContextMenu } from '@desktop-client/hooks/useContextMenu';
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type ToBudgetProps = {
|
||||
month: string;
|
||||
@@ -102,6 +102,8 @@ export function ToBudget({
|
||||
onBudgetAction(month, 'reset-hold');
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
month={month}
|
||||
onBudgetAction={onBudgetAction}
|
||||
/>
|
||||
)}
|
||||
{menuStep === 'buffer' && (
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
useEnvelopeSheetValue,
|
||||
} from '@desktop-client/components/budget/envelope/EnvelopeBudgetComponents';
|
||||
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
|
||||
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type ToBudgetAmountProps = {
|
||||
prevMonthName: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Menu } from '@actual-app/components/menu';
|
||||
|
||||
import { useEnvelopeSheetValue } from '@desktop-client/components/budget/envelope/EnvelopeBudgetComponents';
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type ToBudgetMenuProps = Omit<
|
||||
ComponentPropsWithoutRef<typeof Menu>,
|
||||
@@ -14,18 +14,26 @@ type ToBudgetMenuProps = Omit<
|
||||
onCover: () => void;
|
||||
onHoldBuffer: () => void;
|
||||
onResetHoldBuffer: () => void;
|
||||
onBudgetAction?: (month: string, action: string, arg?: unknown) => void;
|
||||
month: string;
|
||||
};
|
||||
|
||||
export function ToBudgetMenu({
|
||||
onTransfer,
|
||||
onCover,
|
||||
onHoldBuffer,
|
||||
onResetHoldBuffer,
|
||||
onBudgetAction,
|
||||
month,
|
||||
...props
|
||||
}: ToBudgetMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
|
||||
const forNextMonth = useEnvelopeSheetValue(envelopeBudget.forNextMonth) ?? 0;
|
||||
const manualBuffered =
|
||||
useEnvelopeSheetValue(envelopeBudget.manualBuffered) ?? 0;
|
||||
const autoBuffered = useEnvelopeSheetValue(envelopeBudget.autoBuffered) ?? 0;
|
||||
const items = [
|
||||
...(toBudget > 0
|
||||
? [
|
||||
@@ -33,6 +41,10 @@ export function ToBudgetMenu({
|
||||
name: 'transfer',
|
||||
text: t('Move to a category'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(autoBuffered === 0
|
||||
? [
|
||||
{
|
||||
name: 'buffer',
|
||||
text: t('Hold for next month'),
|
||||
@@ -47,7 +59,15 @@ export function ToBudgetMenu({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(forNextMonth > 0
|
||||
...(forNextMonth > 0 && manualBuffered === 0
|
||||
? [
|
||||
{
|
||||
name: 'disable-auto-buffer',
|
||||
text: t('Disable current auto hold'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(forNextMonth > 0 && manualBuffered !== 0
|
||||
? [
|
||||
{
|
||||
name: 'reset-buffer',
|
||||
@@ -70,10 +90,14 @@ export function ToBudgetMenu({
|
||||
break;
|
||||
case 'buffer':
|
||||
onHoldBuffer?.();
|
||||
onBudgetAction?.(month, 'reset-income-carryover', {});
|
||||
break;
|
||||
case 'reset-buffer':
|
||||
onResetHoldBuffer?.();
|
||||
break;
|
||||
case 'disable-auto-buffer':
|
||||
onBudgetAction?.(month, 'reset-income-carryover', {});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unrecognized menu option: ${name}`);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { View } from '@actual-app/components/view';
|
||||
|
||||
import { EnvelopeCellValue } from '@desktop-client/components/budget/envelope/EnvelopeBudgetComponents';
|
||||
import { CellValueText } from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/components/spreadsheet/useFormat';
|
||||
import { envelopeBudget } from '@desktop-client/queries/queries';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type TotalsListProps = {
|
||||
prevMonthName: string;
|
||||
|
||||
@@ -3,11 +3,11 @@ import { useCallback, useMemo, useReducer, useState } from 'react';
|
||||
import { Stack } from '@actual-app/components/stack';
|
||||
import { type CSSProperties } from '@actual-app/components/styles';
|
||||
|
||||
import { type Template } from 'loot-core/server/budget/types/templates';
|
||||
import {
|
||||
type CategoryGroupEntity,
|
||||
type ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import { type Template } from 'loot-core/types/models/templates';
|
||||
|
||||
import { type Action } from './actions';
|
||||
import { BudgetAutomationEditor } from './BudgetAutomationEditor';
|
||||
|
||||