Compare commits
136 Commits
PayeeAutoc
...
feat/auto-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0c00252e3 | ||
|
|
f53d3c662d | ||
|
|
a8a3355354 | ||
|
|
9d51ef7ce8 | ||
|
|
e6d63f620e | ||
|
|
266e7f9cac | ||
|
|
e951e21fe1 | ||
|
|
1a26253457 | ||
|
|
e72f18c5db | ||
|
|
5deb2cf790 | ||
|
|
111e01449d | ||
|
|
c0bd920c26 | ||
|
|
b695af66c0 | ||
|
|
650521f05b | ||
|
|
738a8cda7c | ||
|
|
deadd9aefc | ||
|
|
16ec636358 | ||
|
|
b271de32b6 | ||
|
|
2fb98156f6 | ||
|
|
2f86bafd1f | ||
|
|
7f6f4d5def | ||
|
|
c57260a504 | ||
|
|
1452ecfeb7 | ||
|
|
264cc9fb0e | ||
|
|
554d0b6150 | ||
|
|
11d0b9d824 | ||
|
|
323c2beb0a | ||
|
|
dc5ce6ae96 | ||
|
|
d8afc6b2be | ||
|
|
7732fac8b6 | ||
|
|
6da6f505e6 | ||
|
|
4674916d3e | ||
|
|
5388a115e9 | ||
|
|
06d31ce035 | ||
|
|
9585a92cda | ||
|
|
a0a490c14c | ||
|
|
0d6742664b | ||
|
|
37e807f161 | ||
|
|
c9abc62b3a | ||
|
|
234f257260 | ||
|
|
49d583e4ad | ||
|
|
460cb7b6cd | ||
|
|
1eb68c8e19 | ||
|
|
2ec592c2d6 | ||
|
|
19edbeb5c2 | ||
|
|
d8b7e45aaa | ||
|
|
f71249f510 | ||
|
|
ebf8e985ad | ||
|
|
690e2d0871 | ||
|
|
91faa2f7f1 | ||
|
|
4888d3faed | ||
|
|
3091320719 | ||
|
|
72304c6182 | ||
|
|
e4903ca6e3 | ||
|
|
d768cfa508 | ||
|
|
e0ed53c4af | ||
|
|
c3e3a258e0 | ||
|
|
f55a42d817 | ||
|
|
331aafda30 | ||
|
|
be95b9a3d5 | ||
|
|
8eadd09bfc | ||
|
|
414aa95fd8 | ||
|
|
19ed2423d4 | ||
|
|
4986e433a5 | ||
|
|
9a3415adab | ||
|
|
bcfefde4ad | ||
|
|
c4514b1fe6 | ||
|
|
7a5bfe7c20 | ||
|
|
323403b5f7 | ||
|
|
a35fdf4d18 | ||
|
|
d0a72f10b6 | ||
|
|
b651238ad2 | ||
|
|
8d1f0cf1de | ||
|
|
b50c45c9c9 | ||
|
|
8f2369d5b8 | ||
|
|
713ac88fee | ||
|
|
bcbcd6ad9f | ||
|
|
c9a0ffa91c | ||
|
|
6a9df6562c | ||
|
|
d4144f4b9c | ||
|
|
176336e7f3 | ||
|
|
de0f4e9440 | ||
|
|
65dee4c627 | ||
|
|
9376217c5e | ||
|
|
e6e108ffbd | ||
|
|
517b1b4a81 | ||
|
|
cdae09e554 | ||
|
|
59233c4786 | ||
|
|
502babd310 | ||
|
|
3c302b3af9 | ||
|
|
4c2d3e6998 | ||
|
|
e79d91b000 | ||
|
|
2457a0b454 | ||
|
|
84a1d12dae | ||
|
|
b50d48a31a | ||
|
|
9289932af9 | ||
|
|
fe624f0158 | ||
|
|
7443886856 | ||
|
|
58b1420c60 | ||
|
|
7035b32f26 | ||
|
|
ee0e7ed3e0 | ||
|
|
b6452f930b | ||
|
|
bf814a6873 | ||
|
|
81c5dd347f | ||
|
|
cd87d5a899 | ||
|
|
f5e1d5eab4 | ||
|
|
6bf119786c | ||
|
|
14d4c7748d | ||
|
|
edf61a477a | ||
|
|
0724f7eaef | ||
|
|
d01d0eacb8 | ||
|
|
bb70074f35 | ||
|
|
5860b95c9c | ||
|
|
a58bac6de6 | ||
|
|
d01f38d480 | ||
|
|
4b1f127910 | ||
|
|
f5377ac7b7 | ||
|
|
826ad86ada | ||
|
|
c1720f35fd | ||
|
|
93cce07542 | ||
|
|
b88feb9336 | ||
|
|
0c3a515e29 | ||
|
|
5d82435700 | ||
|
|
bcf53007ca | ||
|
|
64aa88aa22 | ||
|
|
6d8cba1564 | ||
|
|
914074d4b6 | ||
|
|
2b8a15f51c | ||
|
|
0467b13848 | ||
|
|
843e957757 | ||
|
|
073725e270 | ||
|
|
0e20e17fa4 | ||
|
|
f1fd99eeac | ||
|
|
cf3a42792f | ||
|
|
d41af58daf | ||
|
|
25ee19c1e1 |
@@ -3,24 +3,15 @@ issue_enrichment:
|
||||
enabled: false
|
||||
reviews:
|
||||
request_changes_workflow: true
|
||||
review_status: true
|
||||
review_status: false
|
||||
high_level_summary: false
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: false
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: off
|
||||
enabled: false
|
||||
custom_checks:
|
||||
- mode: error
|
||||
name: 'settings'
|
||||
instructions: 'Every addition of a new setting toggle must be thoroughly evaluated against the core design principles of Actual. The settings screen is reserved for essential and foundational options only — do not introduce settings for minor UI adjustments such as sizes, paddings, colors, or margins. Prioritize preserving a simple and uncluttered user experience. Users proposing new settings must confirm in a reply to the Coderabbit comment that they have reviewed and ensured alignment with these principles. Excessive or granular UI options increase code complexity and risk confusing users, and are generally not permitted.'
|
||||
- mode: error
|
||||
name: 'linting'
|
||||
instructions: 'Do not allow any oxlint-disable lines.'
|
||||
- mode: error
|
||||
name: 'typecheck'
|
||||
instructions: 'Do not allow creating new components or utilities with the @ts-strict-ignore comment.'
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI.'
|
||||
|
||||
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -8,35 +8,66 @@ body:
|
||||
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.
|
||||
|
||||
⚠️ **CRITICAL:** Bug reports without clear, step-by-step reproduction instructions will be closed. We cannot investigate or fix bugs without being able to reproduce them. Please take the time to provide detailed reproduction steps.
|
||||
- type: markdown
|
||||
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.
|
||||
|
||||
**Bank-sync problems (SimpleFin / GoCardless)?** Reach out via the [community Discord](https://discord.gg/pRYNYr4W5A) first and open an issue only if the community deems the issue to be a legitimate bug in Actual.
|
||||
- type: checkboxes
|
||||
id: existing-issue
|
||||
attributes:
|
||||
label: 'Verified issue does not already exist?'
|
||||
description: 'Please search to see if an issue already exists for the issue you encountered.'
|
||||
options:
|
||||
- label: 'I have searched and found no existing issue'
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen? If you're reporting an issue with imports, please attach a (redacted) version of the file you're having trouble importing. You may need to zip it before uploading.
|
||||
placeholder: Tell us what you see!
|
||||
value: 'A bug happened!'
|
||||
description: |
|
||||
Describe the bug clearly and concisely. Include:
|
||||
- What you were trying to do
|
||||
- What you expected to happen
|
||||
- What actually happened instead
|
||||
- Any error messages (copy/paste the exact text)
|
||||
|
||||
If you're reporting an issue with imports, please include a (redacted) version of the file, and a screenshot of the import screen. You may need to zip it before uploading.
|
||||
placeholder: |
|
||||
I was trying to [action] when [context].
|
||||
Expected: [expected behavior]
|
||||
Actual: [actual behavior]
|
||||
Error message: [if any]
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
## Reproduction Steps
|
||||
|
||||
**REQUIRED:** Without clear reproduction steps, we cannot investigate or fix the bug. Please provide detailed, step-by-step instructions that anyone can follow to reproduce the issue.
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: How can we reproduce the issue?
|
||||
description: Please give step-by-step instructions on how to reproduce the issue. In most cases this might also require uploading a sample budget/import file.
|
||||
value: 'How can we reproduce the issue?'
|
||||
description: |
|
||||
**This field is mandatory and must be filled out completely.**
|
||||
|
||||
Provide numbered, step-by-step instructions that allow us to reproduce the bug. Include:
|
||||
- Specific actions you took (e.g., "Click on the Budget tab", "Enter $100 in the amount field")
|
||||
- What you expected to happen
|
||||
- What actually happened instead
|
||||
|
||||
Example format:
|
||||
1. Navigate to [specific page/section]
|
||||
2. Click on [specific button/link]
|
||||
3. Enter [specific data] in [specific field]
|
||||
4. Click [action]
|
||||
5. Observe [expected vs actual behavior]
|
||||
|
||||
If the issue involves importing data, please attach a (redacted) sample file. You may need to zip it before uploading.
|
||||
placeholder: |
|
||||
1. Go to [specific location]
|
||||
2. Click [specific element]
|
||||
3. Enter [specific data]
|
||||
4. Click [action]
|
||||
5. Expected: [what should happen]
|
||||
Actual: [what actually happens]
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
|
||||
@@ -28,16 +28,16 @@ try {
|
||||
}
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
model: 'gpt-4.1-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.',
|
||||
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfixes", 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?`,
|
||||
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfixes: 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,
|
||||
@@ -71,30 +71,26 @@ try {
|
||||
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;
|
||||
}
|
||||
//CHANGED HOW IT READS THE CATEGORY TO AVOID ERRORS WHEN LLM DOESNT ANSWER A JSON
|
||||
|
||||
// Validate the category response
|
||||
const validCategories = [
|
||||
'Features',
|
||||
'Bugfix',
|
||||
'Bugfixes',
|
||||
'Enhancements',
|
||||
'Maintenance',
|
||||
];
|
||||
if (validCategories.includes(category)) {
|
||||
const lowerContent = rawContent.toLowerCase();
|
||||
const category = validCategories.find(cat =>
|
||||
lowerContent.includes(cat.toLowerCase()),
|
||||
);
|
||||
if (category) {
|
||||
console.log('OpenAI categorized as:', category);
|
||||
setOutput('result', category);
|
||||
} else {
|
||||
console.log('Invalid category from OpenAI:', category);
|
||||
console.log(
|
||||
'No valid category found in OpenAI response:',
|
||||
rawContent,
|
||||
);
|
||||
console.log('Valid categories are:', validCategories);
|
||||
setOutput('result', 'null');
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ try {
|
||||
console.log('CodeRabbit comment body:', commentBody);
|
||||
|
||||
const data = JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
model: 'gpt-4.1-mini',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
|
||||
@@ -36,11 +36,13 @@ async function getPRDetails() {
|
||||
console.log('- PR Number:', pr.number);
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
console.log('- Base Branch:', pr.base.ref);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
baseBranch: pr.base.ref,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
|
||||
9
.github/actions/docs-spelling/expect.txt
vendored
@@ -5,6 +5,7 @@ Activo
|
||||
AESUDEF
|
||||
ALZEY
|
||||
Anglais
|
||||
ANZ
|
||||
aql
|
||||
AUR
|
||||
Authentik
|
||||
@@ -40,13 +41,13 @@ COBADEFF
|
||||
CODEOWNERS
|
||||
COEP
|
||||
commerzbank
|
||||
COOP
|
||||
Copiar
|
||||
COUNTA
|
||||
COUNTBLANK
|
||||
countif
|
||||
CREGBEBB
|
||||
crt
|
||||
CZK
|
||||
Danske
|
||||
datadir
|
||||
DATEDIF
|
||||
@@ -68,7 +69,6 @@ Fineco
|
||||
Finicity
|
||||
Fintro
|
||||
Finverse
|
||||
flathub
|
||||
Flathub
|
||||
FORTUNEO
|
||||
FTNOFRP
|
||||
@@ -83,6 +83,7 @@ HABAL
|
||||
Hampel
|
||||
HELADEF
|
||||
HLOOKUP
|
||||
HUF
|
||||
IFERROR
|
||||
IFNA
|
||||
INDUSTRIEL
|
||||
@@ -116,12 +117,14 @@ LKR
|
||||
MAXA
|
||||
mbank
|
||||
mdc
|
||||
metainfo
|
||||
modals
|
||||
Moldovan
|
||||
murmurhash
|
||||
NETWORKDAYS
|
||||
nginx
|
||||
OIDC
|
||||
Okabe
|
||||
overbudgeted
|
||||
overbudgeting
|
||||
oxc
|
||||
@@ -134,7 +137,6 @@ prefs
|
||||
Primoco
|
||||
Priotecs
|
||||
proactively
|
||||
pwa
|
||||
Qatari
|
||||
QNTOFRP
|
||||
QONTO
|
||||
@@ -172,6 +174,7 @@ touchscreen
|
||||
triaging
|
||||
UAH
|
||||
ubuntu
|
||||
undici
|
||||
userinfo
|
||||
Userscripts
|
||||
UZS
|
||||
|
||||
2
.github/scripts/count-points.mjs
vendored
@@ -278,7 +278,7 @@ async function countContributorPoints() {
|
||||
|
||||
if (
|
||||
event.event === 'closed' &&
|
||||
event.state_reason === 'not_planned'
|
||||
['not_planned', 'duplicate'].includes(event.state_reason)
|
||||
) {
|
||||
const closer = event.actor.login;
|
||||
const userStats = stats.get(closer);
|
||||
|
||||
30
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -41,8 +41,21 @@ jobs:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
- name: Check if PR targets master branch
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-base-branch
|
||||
run: |
|
||||
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
if [ "$BASE_BRANCH" = "master" ]; then
|
||||
echo "targets_master=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "targets_master=false" >> $GITHUB_OUTPUT
|
||||
echo "PR does not target master branch, skipping release notes generation"
|
||||
fi
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
@@ -70,11 +83,22 @@ jobs:
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
# Internal bot doesn't trigger workflows on commit; switching to Actual Bot App
|
||||
- name: Generate GitHub App token
|
||||
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 != ''
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ vars.ACTUAL_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.ACTUAL_BOT_PRIVATE_KEY }}
|
||||
owner: actualbudget
|
||||
repositories: actual
|
||||
|
||||
- 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.ACTIONS_UPDATE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
@@ -84,7 +108,7 @@ jobs:
|
||||
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_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
33
.github/workflows/docs-release.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Release Docs to Github Pages
|
||||
|
||||
# Release docs on every push to master
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/docs/**'
|
||||
- '.github/workflows/docs-spelling.yml'
|
||||
- '.github/actions/docs-spelling/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Deploy Docs
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Docusaurus Deploy
|
||||
run: |
|
||||
GIT_USER=MikesGlitch \
|
||||
GIT_PASS=${{ secrets.DOCS_GITHUB_PAGES_DEPLOY }} \
|
||||
GIT_USER_NAME=github-actions[bot] \
|
||||
GIT_USER_EMAIL=github-actions[bot]@users.noreply.github.com \
|
||||
yarn deploy:docs
|
||||
8
.github/workflows/e2e-test.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
|
||||
48
.github/workflows/fork-pr-welcome.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Fork PR Welcome
|
||||
|
||||
##########################################################################################
|
||||
# WARNING! This workflow uses the 'pull_request_target' event. That means that it will #
|
||||
# always run in the context of the main actualbudget/actual repo, even if the PR is from #
|
||||
# a fork. This is necessary to get access to a GitHub token that can post a comment on #
|
||||
# the PR. Be VERY CAREFUL about adding things to this workflow, since forks can inject #
|
||||
# arbitrary code into their branch, and can pollute the artifacts we download. Arbitrary #
|
||||
# code execution in this workflow could lead to a compromise of the main repo. #
|
||||
##########################################################################################
|
||||
# See: https://securitylab.github.com/research/github-actions-preventing-pwn-requests #
|
||||
##########################################################################################
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
welcome:
|
||||
name: Post Welcome Message
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
header: fork-pr-welcome
|
||||
hide_and_recreate: true
|
||||
hide_classify: OUTDATED
|
||||
message: |
|
||||
<!-- fork-pr-welcome -->
|
||||
👋 Hello contributor!
|
||||
|
||||
We would love to review your PR! Before we can do that, please make sure:
|
||||
|
||||
- ✅ All CI checks pass
|
||||
- ✅ The PR is moved from draft to open (if applicable)
|
||||
- ✅ The "[WIP]" prefix is removed from the PR title
|
||||
- ✅ All CodeRabbit code review comments are resolved (if you disagree with anything - reply to the bot with your reasoning so we can read through it). The bot will eventually approve the PR.
|
||||
|
||||
We do this to reduce the TOIL the core contributor team has to go through for each PR and to allow for speedy reviews and merges.
|
||||
|
||||
For more information, please see our [Contributing Guide](https://actualbudget.org/docs/contributing/).
|
||||
6
.github/workflows/generate-release-pr.yml
vendored
@@ -35,7 +35,10 @@ jobs:
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version="${{ github.event.inputs.version }}"
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--update)
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
@@ -50,6 +53,7 @@ jobs:
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'
|
||||
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Publish nightly npm packages
|
||||
|
||||
# Nightly npm packages are built daily
|
||||
# Nightly npm packages are built daily at midnight UTC
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
|
||||
6
.github/workflows/size-compare.yml
vendored
@@ -139,7 +139,8 @@ jobs:
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--identifier combined > bundle-stats-comment.md
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -148,4 +149,5 @@ jobs:
|
||||
run: |
|
||||
node packages/ci-actions/bin/update-bundle-stats-comment.mjs \
|
||||
--comment-file bundle-stats-comment.md \
|
||||
--identifier '<!--- bundlestats-action-comment key:combined --->'
|
||||
--identifier combined \
|
||||
--target pr-body
|
||||
|
||||
2
.github/workflows/vrt-update-generate.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.56.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-jammy
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
|
||||
3
.gitignore
vendored
@@ -76,3 +76,6 @@ build/
|
||||
|
||||
# Lage cache
|
||||
.lage/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -4,7 +4,31 @@
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 80,
|
||||
"ignorePatterns": [
|
||||
"packages/docs/*" // TOOD: fixme; temporary
|
||||
]
|
||||
"experimentalSortImports": {
|
||||
"groups": [
|
||||
"react",
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
],
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": ["react"]
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": ["loot-core"]
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": ["@desktop-client"]
|
||||
}
|
||||
],
|
||||
"newlinesBetween": true
|
||||
}
|
||||
}
|
||||
|
||||
109
.oxlintrc.json
@@ -15,44 +15,14 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly" // TODO: remove this
|
||||
"FS": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// TODO fix all these and re-enable
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/tabindex-no-positive": "off",
|
||||
|
||||
// Import sorting
|
||||
// TODO replace once oxfmt supports this: https://github.com/oxc-project/oxc/issues/17076
|
||||
"perfectionist/sort-imports": [
|
||||
"perfectionist/sort-named-imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [
|
||||
"react",
|
||||
"builtin",
|
||||
"external",
|
||||
"loot-core",
|
||||
"parent",
|
||||
"sibling",
|
||||
"index",
|
||||
"desktop-client"
|
||||
],
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": "^react(-.*)?$"
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": "^loot-core"
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": "^@desktop-client"
|
||||
}
|
||||
],
|
||||
"newlinesBetween": "always"
|
||||
"groups": ["value-import", "type-import"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -103,13 +73,7 @@
|
||||
"jsx-a11y/scope": "warn",
|
||||
|
||||
// Typescript rules
|
||||
"typescript/ban-ts-comment": [
|
||||
"warn",
|
||||
{
|
||||
// TODO: remove this
|
||||
"ts-ignore": "allow-with-description"
|
||||
}
|
||||
],
|
||||
"typescript/ban-ts-comment": ["warn"],
|
||||
"typescript/consistent-type-definitions": ["warn", "type"],
|
||||
"typescript/consistent-type-imports": [
|
||||
"warn",
|
||||
@@ -155,7 +119,7 @@
|
||||
"react/exhaustive-deps": [
|
||||
"warn",
|
||||
{
|
||||
"additionalHooks": "(useQuery|useEffectAfterMount)"
|
||||
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-brace-presence": "warn",
|
||||
@@ -190,7 +154,7 @@
|
||||
|
||||
// ESLint rules
|
||||
"eslint/array-callback-return": "warn",
|
||||
// "eslint/curly": ["warn", "multi-line", "consistent"], // TODO: re-enable? this rule is really slow
|
||||
"eslint/curly": ["warn", "multi-line", "consistent"],
|
||||
"eslint/default-case": [
|
||||
"warn",
|
||||
{
|
||||
@@ -369,6 +333,10 @@
|
||||
"importNames": ["colors"],
|
||||
"message": "Please use themes instead of colors"
|
||||
},
|
||||
{
|
||||
"group": ["**/style/themes/*"],
|
||||
"message": "Please do not import theme files directly"
|
||||
},
|
||||
{
|
||||
"group": ["@actual-app/web/**/*"],
|
||||
"message": "Please do not import `@actual-app/web` in `loot-core`"
|
||||
@@ -382,55 +350,9 @@
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
// TODO: fix the issues in these files
|
||||
"files": [
|
||||
"packages/component-library/src/Menu.tsx",
|
||||
"packages/desktop-client/src/components/accounts/Account.jsx",
|
||||
"packages/desktop-client/src/components/accounts/MobileAccount.jsx",
|
||||
"packages/desktop-client/src/components/accounts/MobileAccounts.jsx",
|
||||
"packages/desktop-client/src/components/budget/BudgetCategories.jsx",
|
||||
"packages/desktop-client/src/components/budget/BudgetSummaries.tsx",
|
||||
"packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx",
|
||||
"packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx",
|
||||
"packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx",
|
||||
"packages/desktop-client/src/components/budget/index.tsx",
|
||||
"packages/desktop-client/src/components/budget/MobileBudget.tsx",
|
||||
"packages/desktop-client/src/components/FinancesApp.tsx",
|
||||
"packages/desktop-client/src/components/GlobalKeys.ts",
|
||||
"packages/desktop-client/src/components/LoggedInUser.tsx",
|
||||
"packages/desktop-client/src/components/manager/ManagementApp.jsx",
|
||||
"packages/desktop-client/src/components/manager/subscribe/common.tsx",
|
||||
"packages/desktop-client/src/components/ManageRules.tsx",
|
||||
"packages/desktop-client/src/components/mobile/MobileAmountInput.jsx",
|
||||
"packages/desktop-client/src/components/mobile/MobileNavTabs.tsx",
|
||||
"packages/desktop-client/src/components/Modals.tsx",
|
||||
"packages/desktop-client/src/components/modals/EditRule.jsx",
|
||||
"packages/desktop-client/src/components/modals/ImportTransactions.jsx",
|
||||
"packages/desktop-client/src/components/modals/ImportTransactionsModal/ImportTransactionsModal.tsx",
|
||||
"packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx",
|
||||
"packages/desktop-client/src/components/Notifications.tsx",
|
||||
"packages/desktop-client/src/components/payees/ManagePayees.jsx",
|
||||
"packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx",
|
||||
"packages/desktop-client/src/components/payees/PayeeTable.tsx",
|
||||
"packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx",
|
||||
"packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx",
|
||||
"packages/desktop-client/src/components/reports/reports/CustomReport.jsx",
|
||||
"packages/desktop-client/src/components/reports/reports/CustomReport.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx",
|
||||
"packages/desktop-client/src/components/reports/SaveReportName.tsx",
|
||||
"packages/desktop-client/src/components/reports/useReport.ts",
|
||||
"packages/desktop-client/src/components/schedules/ScheduleDetails.jsx",
|
||||
"packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx",
|
||||
"packages/desktop-client/src/components/schedules/SchedulesTable.tsx",
|
||||
"packages/desktop-client/src/components/select/DateSelect.tsx",
|
||||
"packages/desktop-client/src/components/sidebar/Tools.tsx",
|
||||
"packages/desktop-client/src/components/sort.tsx",
|
||||
"packages/desktop-client/src/hooks/useEffectAfterMount.ts",
|
||||
"packages/desktop-client/src/hooks/useQuery.ts"
|
||||
],
|
||||
"files": ["packages/desktop-electron/**/*"],
|
||||
"rules": {
|
||||
"react/exhaustive-deps": "off"
|
||||
"react/rules-of-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -470,16 +392,9 @@
|
||||
// TODO: enable these
|
||||
{
|
||||
"files": [
|
||||
"packages/desktop-client/src/components/admin/UserAccess/UserAccess.tsx",
|
||||
"packages/desktop-client/src/components/admin/UserDirectory/UserDirectory.tsx",
|
||||
"packages/desktop-client/src/components/budget/BudgetCategories.tsx",
|
||||
"packages/desktop-client/src/components/budget/envelope/BalanceMovementMenu.tsx",
|
||||
"packages/desktop-client/src/components/ManageRules.tsx",
|
||||
"packages/desktop-client/src/components/mobile/budget/ExpenseGroupList.tsx",
|
||||
"packages/desktop-client/src/components/modals/EditFieldModal.tsx",
|
||||
"packages/desktop-client/src/components/reports/reports/Calendar.tsx",
|
||||
"packages/desktop-client/src/components/schedules/ScheduleLink.tsx",
|
||||
"packages/desktop-client/src/components/ServerContext.tsx",
|
||||
"packages/desktop-client/src/components/table.tsx"
|
||||
],
|
||||
"rules": {
|
||||
|
||||
12
AGENTS.md
@@ -169,7 +169,7 @@ Custom ESLint rules specific to Actual.
|
||||
|
||||
- `no-untranslated-strings`: Enforces i18n usage
|
||||
- `prefer-trans-over-t`: Prefers Trans component over t() function
|
||||
- `prefer-logger-over-console`: Enforces using logger instead of console
|
||||
- `prefer-logger-over-console`: Enforces using logger instead of console in `packages/loot-core/`
|
||||
- `typography`: Typography rules
|
||||
- `prefer-if-statement`: Prefers explicit if statements
|
||||
|
||||
@@ -259,6 +259,10 @@ Always run `yarn typecheck` before committing.
|
||||
- Generate i18n files: `yarn generate:i18n`
|
||||
- Custom ESLint rules enforce translation usage
|
||||
|
||||
### 5. Financial Number Typography
|
||||
|
||||
- Wrap standalone financial numbers with `FinancialText` or apply `styles.tnum` directly if wrapping is not possible
|
||||
|
||||
## Code Style & Conventions
|
||||
|
||||
### TypeScript Guidelines
|
||||
@@ -328,7 +332,6 @@ Always maintain newlines between import groups.
|
||||
|
||||
**Never:**
|
||||
|
||||
- Use `console.*` (use logger instead - enforced by ESLint)
|
||||
- Import from `uuid` without destructuring: use `import { v4 as uuidv4 } from 'uuid'`
|
||||
- Import colors directly - use theme instead
|
||||
- Import `@actual-app/web/*` in `loot-core`
|
||||
@@ -541,7 +544,6 @@ Before committing changes, ensure:
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] No new console.\* usage (use logger)
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Prefer `type` over `interface`
|
||||
- [ ] Named exports used (not default exports)
|
||||
@@ -555,6 +557,10 @@ When creating pull requests:
|
||||
|
||||
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
When performing code reviews (especially for LLM agents): **see [CODE_REVIEW_GUIDELINES.md](./CODE_REVIEW_GUIDELINES.md)** for specific guidelines.
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Bundle Size**: Check with rollup-plugin-visualizer
|
||||
|
||||
94
CODE_REVIEW_GUIDELINES.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# CODE_REVIEW_GUIDELINES.md - Guidelines for LLM Agents Performing Code Reviews
|
||||
|
||||
This document provides specific guidelines for LLM agents performing code reviews on the Actual Budget codebase. These guidelines help maintain code quality, consistency, and follow the project's design principles.
|
||||
|
||||
## Settings Proliferation
|
||||
|
||||
**Do NOT add new settings for every little UI tweak.**
|
||||
|
||||
Actual Budget follows a design philosophy that prioritizes simplicity and avoids settings bloat. Before introducing code that adds new settings:
|
||||
|
||||
- Consider if the UI tweak can be achieved through existing theme/design tokens
|
||||
- Evaluate whether the setting provides meaningful value to users
|
||||
- Check if the change aligns with Actual's design guidelines
|
||||
- Prefer hardcoded values or theme-based solutions over adding user-facing settings
|
||||
|
||||
## TypeScript Strict Mode Suppressions
|
||||
|
||||
**Do NOT approve code that adds new `@ts-strict-ignore` comments.**
|
||||
|
||||
The project uses strict TypeScript checking via `typescript-strict-plugin`. Adding `@ts-strict-ignore` comments undermines type safety. Instead, review should encourage:
|
||||
|
||||
- Fixing the underlying type issue
|
||||
- Using proper type definitions
|
||||
- Refactoring code to satisfy strict type checking
|
||||
- Only in exceptional cases, document why strict checking cannot be applied and seek alternative solutions
|
||||
|
||||
## Linter Suppressions
|
||||
|
||||
**Do NOT approve code that adds new `eslint-disable` or `oxlint-disable` comments.**
|
||||
|
||||
Linter rules are in place for good reasons. Instead of suppressing them:
|
||||
|
||||
- Fix the underlying issue
|
||||
- If the rule is incorrectly flagging valid code, consider if the code can be refactored
|
||||
- Only approve suppressions if there's a documented, exceptional reason
|
||||
|
||||
## Type Assertions
|
||||
|
||||
**Prefer `x satisfies SomeType` over `x as SomeType` for type coercions.**
|
||||
|
||||
The `satisfies` operator provides better type safety by:
|
||||
|
||||
- Ensuring the value actually satisfies the type (narrowing)
|
||||
- Preserving the actual type information for better inference
|
||||
- Catching type mismatches at compile time
|
||||
|
||||
**Exception:** If you truly need to assert a type that TypeScript cannot verify (e.g., runtime type guards), use `as` but require a comment explaining why it's safe.
|
||||
|
||||
## Avoiding `any` and `unknown`
|
||||
|
||||
**Flag code that uses `any` or `unknown` unless absolutely necessary.**
|
||||
|
||||
The use of `any` or `unknown` should be rare and well-justified. Before approving:
|
||||
|
||||
- Require explicit justification for why the type cannot be determined
|
||||
- Suggest using proper type definitions or generics
|
||||
- Consider if the type can be narrowed or properly inferred
|
||||
- Look for existing type definitions in `packages/loot-core/src/types/`
|
||||
|
||||
Only approve `any` or `unknown` if there's a documented, exceptional reason (e.g., interop with untyped external libraries, gradual migration).
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
**All user-facing strings must be translated.**
|
||||
|
||||
The project has custom ESLint rules (`actual/no-untranslated-strings`) that enforce i18n usage, but reviewers should actively flag untranslated strings:
|
||||
|
||||
- Use `Trans` component instead of `t()` function when possible
|
||||
- All text visible to users must use i18n functions
|
||||
- Flag hardcoded strings that should be translated
|
||||
|
||||
## Test Mocking
|
||||
|
||||
**Minimize mocked dependencies; prefer real implementations.**
|
||||
|
||||
When reviewing tests, encourage the use of real implementations over mocks:
|
||||
|
||||
- Prefer real dependencies, utilities, and data structures
|
||||
- Only mock when the real implementation is impractical (e.g., external APIs, file system in unit tests)
|
||||
- Ensure mocks accurately represent real behavior
|
||||
|
||||
Over-mocking makes tests brittle and less reliable. Real implementations provide better confidence that code works correctly.
|
||||
|
||||
## Financial Number Typography
|
||||
|
||||
Standalone financial numbers should have tabular number styles applied.
|
||||
|
||||
- Standalone financial numbers should be wrapped with `FinancialText` or `styles.tnum` should be applied directly if wrapping is not possible
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- See [AGENTS.md](./AGENTS.md) for general development guidelines
|
||||
- See [CONTRIBUTING.md](./CONTRIBUTING.md) for contribution guidelines
|
||||
- Community documentation: [https://actualbudget.org/docs/contributing/](https://actualbudget.org/docs/contributing/)
|
||||
@@ -37,7 +37,7 @@ async function run() {
|
||||
choices: [
|
||||
{ title: '✨ Features', value: 'Features' },
|
||||
{ title: '👍 Enhancements', value: 'Enhancements' },
|
||||
{ title: '🐛 Bugfix', value: 'Bugfix' },
|
||||
{ title: '🐛 Bugfixes', value: 'Bugfixes' },
|
||||
{ title: '⚙️ Maintenance', value: 'Maintenance' },
|
||||
],
|
||||
},
|
||||
@@ -160,7 +160,8 @@ category: ${type}
|
||||
authors: [${username}]
|
||||
---
|
||||
|
||||
${summary}`;
|
||||
${summary}
|
||||
`;
|
||||
}
|
||||
|
||||
// simple exec that fails silently and returns an empty string on failure
|
||||
|
||||
@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
|
||||
echo "E2E_START_URL: $E2E_START_URL"
|
||||
echo "VRT_ARGS: $VRT_ARGS"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"start:service-plugins": "yarn workspace plugins-service watch",
|
||||
"start:browser-backend": "yarn workspace loot-core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build:browser-backend": "yarn workspace loot-core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
@@ -40,6 +41,7 @@
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"build:docs": "yarn workspace docs build",
|
||||
"build:storybook": "yarn workspace @actual-app/components build:storybook",
|
||||
"deploy:docs": "yarn workspace docs deploy",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
@@ -61,8 +63,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
@@ -74,8 +77,8 @@
|
||||
"minimatch": "^10.1.1",
|
||||
"node-jq": "^6.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.22.0",
|
||||
"oxlint": "^1.37.0",
|
||||
"oxfmt": "^0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
// loot-core types
|
||||
import type { InitConfig } from 'loot-core/server/main';
|
||||
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
import * as injected from './injected';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.1.0",
|
||||
"version": "26.2.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^13.0.0"
|
||||
@@ -29,7 +29,7 @@
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.9"
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// oxlint-disable-next-line typescript/ban-ts-comment
|
||||
// @ts-ignore: bundle not available until we build it
|
||||
import * as bundle from './app/bundle.api.js';
|
||||
|
||||
|
||||
@@ -174,6 +174,7 @@ function parseArgs(argv) {
|
||||
return {
|
||||
sections,
|
||||
identifier: getSingleValue(args, 'identifier') ?? 'bundle-stats',
|
||||
format: getSingleValue(args, 'format') ?? 'pr-body',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,6 +464,12 @@ const TOTAL_HEADERS = makeHeader([
|
||||
'Total bundle size',
|
||||
'% Changed',
|
||||
]);
|
||||
const SUMMARY_HEADERS = makeHeader([
|
||||
'Bundle',
|
||||
'Files count',
|
||||
'Total bundle size',
|
||||
'% Changed',
|
||||
]);
|
||||
const TABLE_HEADERS = makeHeader(['Asset', 'File Size', '% Changed']);
|
||||
const CHUNK_TABLE_HEADERS = makeHeader(['File', 'Δ', 'Size']);
|
||||
|
||||
@@ -596,6 +603,24 @@ function printTotalAssetTable(statsDiff) {
|
||||
return `**Total**\n${TOTAL_HEADERS}\n${printAssetTableRow(statsDiff.total)}`;
|
||||
}
|
||||
|
||||
function printSummaryTable(sections) {
|
||||
if (sections.length === 0) {
|
||||
return `${SUMMARY_HEADERS}\nNo bundle stats were generated.`;
|
||||
}
|
||||
|
||||
const rows = sections.map(section => {
|
||||
const total = section.statsDiff.total;
|
||||
return [
|
||||
section.name,
|
||||
total.name,
|
||||
toFileSizeDiffCell(total),
|
||||
conditionalPercentage(total.diffPercentage),
|
||||
].join(' | ');
|
||||
});
|
||||
|
||||
return `${SUMMARY_HEADERS}\n${rows.join('\n')}`;
|
||||
}
|
||||
|
||||
function renderSection(title, statsDiff, chunkModuleDiff) {
|
||||
const { total, ...groups } = statsDiff;
|
||||
const parts = [`#### ${title}`, '', printTotalAssetTable({ total })];
|
||||
@@ -615,8 +640,30 @@ function renderSection(title, statsDiff, chunkModuleDiff) {
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function renderSections(sections) {
|
||||
return sections
|
||||
.map(section =>
|
||||
renderSection(section.name, section.statsDiff, section.chunkDiff),
|
||||
)
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
function getIdentifierMarkers(key) {
|
||||
const label = 'bundlestats-action-comment';
|
||||
return {
|
||||
start: `<!--- ${label} key:${key} start --->`,
|
||||
end: `<!--- ${label} key:${key} end --->`,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const allowedFormats = new Set(['comment', 'pr-body']);
|
||||
if (!allowedFormats.has(args.format)) {
|
||||
throw new Error(
|
||||
`Invalid format "${args.format}". Use "comment" or "pr-body".`,
|
||||
);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[bundle-stats] Found ${args.sections.length} sections to process`,
|
||||
@@ -654,22 +701,29 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const identifier = `<!--- bundlestats-action-comment key:${args.identifier} --->`;
|
||||
const markers = getIdentifierMarkers(args.identifier);
|
||||
const sectionsContent = renderSections(sections);
|
||||
const summaryTable = printSummaryTable(sections);
|
||||
|
||||
const comment = [
|
||||
const detailedBody = ['### Bundle Stats', '', sectionsContent].join('\n');
|
||||
|
||||
const commentBody = [markers.start, detailedBody, '', markers.end, ''].join(
|
||||
'\n',
|
||||
);
|
||||
|
||||
const prBody = [
|
||||
markers.start,
|
||||
'### Bundle Stats',
|
||||
'',
|
||||
sections
|
||||
.map(section =>
|
||||
renderSection(section.name, section.statsDiff, section.chunkDiff),
|
||||
)
|
||||
.join('\n\n---\n\n'),
|
||||
summaryTable,
|
||||
'',
|
||||
identifier,
|
||||
`<details>\n<summary>View detailed bundle stats</summary>\n\n${sectionsContent}\n</details>`,
|
||||
'',
|
||||
markers.end,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
process.stdout.write(comment);
|
||||
process.stdout.write(args.format === 'comment' ? commentBody : prBody);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
|
||||
@@ -19,6 +19,10 @@ const options = {
|
||||
type: 'string', // nightly, hotfix, monthly, auto
|
||||
short: 't',
|
||||
},
|
||||
version: {
|
||||
type: 'string',
|
||||
short: 'v',
|
||||
},
|
||||
update: {
|
||||
type: 'boolean',
|
||||
short: 'u',
|
||||
@@ -44,16 +48,21 @@ try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
const explicitVersion = values.version;
|
||||
let newVersion;
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
if (explicitVersion) {
|
||||
newVersion = explicitVersion;
|
||||
} else {
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
process.stdout.write(newVersion);
|
||||
|
||||
@@ -14,10 +14,14 @@ import process from 'node:process';
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const BOT_BOUNDARY_MARKER = '<!--- actual-bot-sections --->';
|
||||
const BOT_BOUNDARY_TEXT = `${BOT_BOUNDARY_MARKER}\n<hr />`;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
commentFile: null,
|
||||
identifier: null,
|
||||
target: 'comment',
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i += 2) {
|
||||
@@ -41,6 +45,9 @@ function parseArgs(argv) {
|
||||
case '--identifier':
|
||||
args.identifier = value;
|
||||
break;
|
||||
case '--target':
|
||||
args.target = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument "${key}".`);
|
||||
}
|
||||
@@ -54,6 +61,12 @@ function parseArgs(argv) {
|
||||
throw new Error('Missing required argument "--identifier".');
|
||||
}
|
||||
|
||||
if (!['comment', 'pr-body'].includes(args.target)) {
|
||||
throw new Error(
|
||||
`Invalid value "${args.target}" for "--target". Use "comment" or "pr-body".`,
|
||||
);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
@@ -110,20 +123,123 @@ function isGitHubActionsBot(comment) {
|
||||
return comment.user?.login === 'github-actions[bot]';
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function getIdentifierMarkers(identifier) {
|
||||
if (identifier.includes('<!---')) {
|
||||
return {
|
||||
start: identifier,
|
||||
end: null,
|
||||
};
|
||||
}
|
||||
|
||||
const label = 'bundlestats-action-comment';
|
||||
return {
|
||||
start: `<!--- ${label} key:${identifier} start --->`,
|
||||
end: `<!--- ${label} key:${identifier} end --->`,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertBlock(existingBody, block, markers) {
|
||||
const body = existingBody ?? '';
|
||||
|
||||
if (markers.end) {
|
||||
const pattern = new RegExp(
|
||||
`${escapeRegExp(markers.start)}[\\s\\S]*?${escapeRegExp(markers.end)}`,
|
||||
'm',
|
||||
);
|
||||
|
||||
if (pattern.test(body)) {
|
||||
return body.replace(pattern, block.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (body.trim().length === 0) {
|
||||
return block.trim();
|
||||
}
|
||||
|
||||
const separator = body.endsWith('\n') ? '\n' : '\n\n';
|
||||
const boundary = body.includes(BOT_BOUNDARY_MARKER)
|
||||
? ''
|
||||
: `${BOT_BOUNDARY_TEXT}\n\n`;
|
||||
return `${body}${separator}${boundary}${block.trim()}`;
|
||||
}
|
||||
|
||||
async function updatePullRequestBody(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
pullNumber,
|
||||
block,
|
||||
markers,
|
||||
) {
|
||||
const { data } = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
const nextBody = upsertBlock(data.body ?? '', block, markers);
|
||||
|
||||
await octokit.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
body: nextBody,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteExistingComment(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
markers,
|
||||
) {
|
||||
const comments = await listComments(octokit, owner, repo, issueNumber);
|
||||
const existingComment = comments.find(
|
||||
comment =>
|
||||
isGitHubActionsBot(comment) && comment.body?.includes(markers.start),
|
||||
);
|
||||
|
||||
if (existingComment) {
|
||||
await octokit.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existingComment.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { commentFile, identifier } = parseArgs(process.argv);
|
||||
const { commentFile, identifier, target } = parseArgs(process.argv);
|
||||
const commentBody = await loadCommentBody(commentFile);
|
||||
const token = assertGitHubToken();
|
||||
const { owner, repo } = getRepoInfo();
|
||||
const issueNumber = getPullRequestNumber();
|
||||
const markers = getIdentifierMarkers(identifier);
|
||||
|
||||
const octokit = new Octokit({ auth: token });
|
||||
|
||||
const comments = await listComments(octokit, owner, repo, issueNumber);
|
||||
if (target === 'pr-body') {
|
||||
await updatePullRequestBody(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
commentBody,
|
||||
markers,
|
||||
);
|
||||
await deleteExistingComment(octokit, owner, repo, issueNumber, markers);
|
||||
console.log('Updated pull request body with bundle stats.');
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = await listComments(octokit, owner, repo, issueNumber);
|
||||
const existingComment = comments.find(
|
||||
comment =>
|
||||
isGitHubActionsBot(comment) && comment.body?.includes(identifier),
|
||||
isGitHubActionsBot(comment) && comment.body?.includes(markers.start),
|
||||
);
|
||||
|
||||
if (existingComment) {
|
||||
@@ -134,15 +250,16 @@ async function main() {
|
||||
body: commentBody,
|
||||
});
|
||||
console.log('Updated existing bundle stats comment.');
|
||||
} else {
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
console.log('Created new bundle stats comment.');
|
||||
return;
|
||||
}
|
||||
|
||||
await octokit.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
console.log('Created new bundle stats comment.');
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"test": "vitest --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.9"
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getNextVersion } from './get-next-package-version';
|
||||
|
||||
|
||||
3
packages/component-library/.oxlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"jsPlugins": ["eslint-plugin-storybook"]
|
||||
}
|
||||
44
packages/component-library/.storybook/main.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
import viteTsconfigPaths from 'vite-tsconfig-paths';
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
function getAbsolutePath(value: string) {
|
||||
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../src/Introduction.mdx',
|
||||
'../src/**/*.mdx',
|
||||
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
],
|
||||
addons: [
|
||||
getAbsolutePath('@chromatic-com/storybook'),
|
||||
getAbsolutePath('@storybook/addon-a11y'),
|
||||
getAbsolutePath('@storybook/addon-docs'),
|
||||
],
|
||||
framework: getAbsolutePath('@storybook/react-vite'),
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import('vite');
|
||||
|
||||
return mergeConfig(config, {
|
||||
// Telling Vite how to resolve path aliases
|
||||
plugins: [viteTsconfigPaths({ root: '../..' })],
|
||||
esbuild: {
|
||||
// Needed to handle JSX in .ts/.tsx files
|
||||
jsx: 'automatic',
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
74
packages/component-library/.storybook/manager.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { addons } from 'storybook/manager-api';
|
||||
import { create } from 'storybook/theming/create';
|
||||
|
||||
// Colors from the Actual Budget light theme palette
|
||||
const purple500 = '#8719e0';
|
||||
const purple400 = '#9a3de8';
|
||||
const navy900 = '#102a43';
|
||||
const navy700 = '#334e68';
|
||||
const navy600 = '#486581';
|
||||
const navy150 = '#d9e2ec';
|
||||
const navy100 = '#e8ecf0';
|
||||
const white = '#ffffff';
|
||||
|
||||
// Create a custom Storybook theme matching Actual Budget's light theme
|
||||
const theme = create({
|
||||
base: 'light',
|
||||
brandTitle: 'Actual Budget',
|
||||
brandUrl: 'https://actualbudget.org',
|
||||
brandImage: 'https://actualbudget.org/img/actual.webp',
|
||||
brandTarget: '_blank',
|
||||
|
||||
// UI colors
|
||||
colorPrimary: purple500,
|
||||
colorSecondary: purple400,
|
||||
|
||||
// App chrome
|
||||
appBg: navy100,
|
||||
appContentBg: white,
|
||||
appPreviewBg: white,
|
||||
appBorderColor: navy150,
|
||||
appBorderRadius: 4,
|
||||
|
||||
// Fonts
|
||||
fontBase:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
fontCode: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
|
||||
// Text colors
|
||||
textColor: navy900,
|
||||
textInverseColor: white,
|
||||
textMutedColor: navy600,
|
||||
|
||||
// Toolbar
|
||||
barTextColor: navy700,
|
||||
barHoverColor: purple500,
|
||||
barSelectedColor: purple500,
|
||||
barBg: white,
|
||||
|
||||
// Form colors
|
||||
buttonBg: white,
|
||||
buttonBorder: navy900,
|
||||
booleanBg: navy150,
|
||||
booleanSelectedBg: purple500,
|
||||
inputBg: white,
|
||||
inputBorder: navy900,
|
||||
inputTextColor: navy900,
|
||||
inputBorderRadius: 4,
|
||||
});
|
||||
|
||||
addons.setConfig({
|
||||
theme,
|
||||
enableShortcuts: true,
|
||||
isFullscreen: false,
|
||||
isToolshown: true,
|
||||
sidebar: {
|
||||
collapsedRoots: [],
|
||||
filters: {
|
||||
patterns: item => {
|
||||
// Hide stories that are marked as internal
|
||||
return !item.tags?.includes('internal');
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
88
packages/component-library/.storybook/preview.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
|
||||
|
||||
const THEMES = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
midnight: midnightTheme,
|
||||
development: developmentTheme,
|
||||
} as const;
|
||||
|
||||
type ThemeName = keyof typeof THEMES;
|
||||
|
||||
const ThemedStory = ({
|
||||
themeName,
|
||||
children,
|
||||
}: {
|
||||
themeName?: ThemeName;
|
||||
children?: ReactNode;
|
||||
}) => {
|
||||
if (!themeName || !THEMES[themeName]) {
|
||||
throw new Error(`No theme specified`);
|
||||
}
|
||||
|
||||
const css = Object.entries(THEMES[themeName])
|
||||
.map(([key, value]) => `--color-${key}: ${value};`)
|
||||
.join('\n');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<style>{`:root {\n${css}}`}</style>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story, { globals }) => {
|
||||
const themeName = globals.theme;
|
||||
|
||||
return (
|
||||
<ThemedStory themeName={themeName}>
|
||||
<Story />
|
||||
</ThemedStory>
|
||||
);
|
||||
},
|
||||
],
|
||||
globalTypes: {
|
||||
theme: {
|
||||
name: 'Theme',
|
||||
description: 'Global theme for components',
|
||||
defaultValue: 'light',
|
||||
toolbar: {
|
||||
icon: 'circlehollow',
|
||||
items: [
|
||||
{ value: 'light', title: 'Light' },
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'midnight', title: 'Midnight' },
|
||||
{ value: 'development', title: 'Development' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
controls: {
|
||||
matchers: {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
},
|
||||
|
||||
a11y: {
|
||||
// 'todo' - show a11y violations in the test UI only
|
||||
// 'error' - fail CI on a11y violations
|
||||
// 'off' - skip a11y checks entirely
|
||||
test: 'todo',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
9
packages/component-library/.storybook/public/_headers
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
# /assets folder contain processed assets with a file hash
|
||||
# They are safe for immutable caching, as filename change when content change
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public
|
||||
Cache-Control: max-age=365000000
|
||||
Cache-Control: immutable
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"./block": "./src/Block.tsx",
|
||||
"./button": "./src/Button.tsx",
|
||||
"./card": "./src/Card.tsx",
|
||||
"./combo-box": "./src/ComboBox.tsx",
|
||||
"./form-error": "./src/FormError.tsx",
|
||||
"./initial-focus": "./src/InitialFocus.ts",
|
||||
"./inline-field": "./src/InlineField.tsx",
|
||||
@@ -38,19 +37,27 @@
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
"test": "npm-run-all -cp 'test:*'",
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts"
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
|
||||
"start:storybook": "storybook dev -p 6006",
|
||||
"build:storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"react-aria-components": "^1.13.0",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "^10.2.0",
|
||||
"@storybook/addon-docs": "^10.2.0",
|
||||
"@storybook/react-vite": "^10.2.0",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"vitest": "^4.0.9"
|
||||
"eslint-plugin-storybook": "^10.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"storybook": "^10.2.0",
|
||||
"vitest": "^4.0.16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2",
|
||||
|
||||
139
packages/component-library/src/AlignedText.stories.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { AlignedText } from './AlignedText';
|
||||
|
||||
const meta = {
|
||||
title: 'AlignedText',
|
||||
component: AlignedText,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AlignedText>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
left: 'Label',
|
||||
right: 'Value',
|
||||
style: { width: 300, display: 'flex' },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'AlignedText displays two pieces of content aligned on opposite sides.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TruncateLeft: Story = {
|
||||
args: {
|
||||
left: 'This is a very long label that should be truncated on the left side',
|
||||
right: '$100.00',
|
||||
truncate: 'left',
|
||||
style: { width: 250, display: 'flex' },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'When `truncate="left"`, the left content is truncated with ellipsis.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TruncateRight: Story = {
|
||||
args: {
|
||||
left: 'Short Label',
|
||||
right:
|
||||
'This is a very long value that should be truncated on the right side',
|
||||
truncate: 'right',
|
||||
style: { width: 250, display: 'flex' },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'When `truncate="right"`, the right content is truncated with ellipsis.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FinancialAmount: Story = {
|
||||
args: {
|
||||
left: 'Groceries',
|
||||
right: '$1,234.56',
|
||||
style: { width: 300, display: 'flex' },
|
||||
rightStyle: { fontWeight: 'bold' },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Example showing AlignedText used for displaying financial data.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStyles: Story = {
|
||||
args: {
|
||||
left: 'Category',
|
||||
right: 'Amount',
|
||||
style: {
|
||||
width: 300,
|
||||
padding: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
},
|
||||
leftStyle: { color: '#666', fontStyle: 'italic' },
|
||||
rightStyle: { color: '#333', fontWeight: 'bold' },
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleRows: Story = {
|
||||
args: {
|
||||
left: 'Income',
|
||||
right: '$5,000.00',
|
||||
},
|
||||
render: () => (
|
||||
<div
|
||||
style={{ width: 300, display: 'flex', flexDirection: 'column', gap: 8 }}
|
||||
>
|
||||
<AlignedText
|
||||
left="Income"
|
||||
right="$5,000.00"
|
||||
rightStyle={{ color: 'green' }}
|
||||
style={{ display: 'flex' }}
|
||||
/>
|
||||
<AlignedText
|
||||
left="Expenses"
|
||||
right="-$3,200.00"
|
||||
rightStyle={{ color: 'red' }}
|
||||
style={{ display: 'flex' }}
|
||||
/>
|
||||
<AlignedText
|
||||
left="Balance"
|
||||
right="$1,800.00"
|
||||
style={{ borderTop: '1px solid #ccc', paddingTop: 8, display: 'flex' }}
|
||||
rightStyle={{ fontWeight: 'bold' }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Multiple AlignedText components stacked to create a summary view.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, type ReactNode, type CSSProperties } from 'react';
|
||||
import { type ComponentProps, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import { Block } from './Block';
|
||||
import { View } from './View';
|
||||
|
||||
111
packages/component-library/src/Block.stories.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Block } from './Block';
|
||||
import { theme } from './theme';
|
||||
|
||||
const meta = {
|
||||
title: 'Block',
|
||||
component: Block,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
} satisfies Meta<typeof Block>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'This is a Block component',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Block is a basic div wrapper that accepts Emotion CSS styles via the `style` prop.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export const WithStyles: Story = {
|
||||
args: {
|
||||
children: 'Styled Block',
|
||||
style: {
|
||||
padding: 20,
|
||||
backgroundColor: theme.cardBackground,
|
||||
borderRadius: 8,
|
||||
border: `1px solid ${theme.cardBorder}`,
|
||||
color: theme.pageText,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFlexLayout: Story = {
|
||||
render: () => (
|
||||
<Block
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
padding: 15,
|
||||
borderRadius: 4,
|
||||
color: theme.pageText,
|
||||
}}
|
||||
>
|
||||
<Block
|
||||
style={{
|
||||
padding: 10,
|
||||
backgroundColor: theme.cardBackground,
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${theme.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
Item 1
|
||||
</Block>
|
||||
<Block
|
||||
style={{
|
||||
padding: 10,
|
||||
backgroundColor: theme.cardBackground,
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${theme.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
Item 2
|
||||
</Block>
|
||||
<Block
|
||||
style={{
|
||||
padding: 10,
|
||||
backgroundColor: theme.cardBackground,
|
||||
borderRadius: 4,
|
||||
border: `1px solid ${theme.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
Item 3
|
||||
</Block>
|
||||
</Block>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Block components can be nested and styled with flexbox.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const AsContainer: Story = {
|
||||
args: {
|
||||
children: 'Container Block',
|
||||
style: {
|
||||
width: 300,
|
||||
padding: 25,
|
||||
textAlign: 'center',
|
||||
backgroundColor: theme.cardBackground,
|
||||
border: `2px dashed ${theme.cardBorder}`,
|
||||
borderRadius: 8,
|
||||
color: theme.pageText,
|
||||
},
|
||||
},
|
||||
};
|
||||
99
packages/component-library/src/Button.stories.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
const meta = {
|
||||
title: 'Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
onClick: { action: 'clicked' },
|
||||
},
|
||||
args: { onClick: fn() },
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
bounce: false,
|
||||
children: 'Button Text',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Primary button variant uses the following theme CSS variables:
|
||||
- \`--color-buttonPrimaryText\`
|
||||
- \`--color-buttonPrimaryTextHover\`
|
||||
- \`--color-buttonPrimaryBackground\`
|
||||
- \`--color-buttonPrimaryBackgroundHover\`
|
||||
- \`--color-buttonPrimaryBorder\`
|
||||
- \`--color-buttonPrimaryShadow\`
|
||||
- \`--color-buttonPrimaryDisabledText\`
|
||||
- \`--color-buttonPrimaryDisabledBackground\`
|
||||
- \`--color-buttonPrimaryDisabledBorder\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Normal: Story = {
|
||||
args: {
|
||||
variant: 'normal',
|
||||
bounce: false,
|
||||
children: 'Button Text',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Normal button variant uses the following theme CSS variables:
|
||||
- \`--color-buttonNormalText\`
|
||||
- \`--color-buttonNormalTextHover\`
|
||||
- \`--color-buttonNormalBackground\`
|
||||
- \`--color-buttonNormalBackgroundHover\`
|
||||
- \`--color-buttonNormalBorder\`
|
||||
- \`--color-buttonNormalShadow\`
|
||||
- \`--color-buttonNormalSelectedText\`
|
||||
- \`--color-buttonNormalSelectedBackground\`
|
||||
- \`--color-buttonNormalDisabledText\`
|
||||
- \`--color-buttonNormalDisabledBackground\`
|
||||
- \`--color-buttonNormalDisabledBorder\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Bare: Story = {
|
||||
args: {
|
||||
variant: 'bare',
|
||||
bounce: false,
|
||||
children: 'Button Text',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Bare button variant uses the following theme CSS variables:
|
||||
- \`--color-buttonBareText\`
|
||||
- \`--color-buttonBareTextHover\`
|
||||
- \`--color-buttonBareBackground\`
|
||||
- \`--color-buttonBareBackgroundHover\`
|
||||
- \`--color-buttonBareBackgroundActive\`
|
||||
- \`--color-buttonBareDisabledText\`
|
||||
- \`--color-buttonBareDisabledBackground\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,8 @@ import React, {
|
||||
forwardRef,
|
||||
useMemo,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { Button as ReactAriaButton } from 'react-aria-components';
|
||||
|
||||
|
||||
82
packages/component-library/src/Card.stories.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Card } from './Card';
|
||||
import { Paragraph } from './Paragraph';
|
||||
import { theme } from './theme';
|
||||
|
||||
const meta = {
|
||||
title: 'Card',
|
||||
component: Card,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Card>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Card content goes here',
|
||||
style: {
|
||||
padding: 20,
|
||||
width: 300,
|
||||
color: theme.pageText,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: `
|
||||
Default Card component uses the following theme CSS variables:
|
||||
- \`--color-cardBackground\`
|
||||
- \`--color-cardBorder\`
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomContent: Story = {
|
||||
args: {
|
||||
style: {
|
||||
padding: 20,
|
||||
width: 300,
|
||||
color: theme.pageText,
|
||||
},
|
||||
},
|
||||
render: args => (
|
||||
<Card {...args}>
|
||||
<h3 style={{ ...styles.largeText }}>Card Title</h3>
|
||||
<Paragraph style={{ margin: 0 }}>
|
||||
This is a card with more complex content including a title and
|
||||
paragraph.
|
||||
</Paragraph>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const Narrow: Story = {
|
||||
args: {
|
||||
children: 'Narrow card',
|
||||
style: {
|
||||
padding: 15,
|
||||
width: 150,
|
||||
color: theme.pageText,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Wide: Story = {
|
||||
args: {
|
||||
children: 'Wide card with more content space',
|
||||
style: {
|
||||
padding: 25,
|
||||
width: 500,
|
||||
color: theme.pageText,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, forwardRef } from 'react';
|
||||
import { forwardRef, type ComponentProps } from 'react';
|
||||
|
||||
import { theme } from './theme';
|
||||
import { View } from './View';
|
||||
|
||||
108
packages/component-library/src/ColorPicker.stories.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { ColorSwatch } from 'react-aria-components';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { fn } from 'storybook/test';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { ColorPicker } from './ColorPicker';
|
||||
|
||||
const meta = {
|
||||
title: 'ColorPicker',
|
||||
component: ColorPicker,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
onChange: fn(),
|
||||
children: <Button>Pick a color</Button>,
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof ColorPicker>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
defaultValue: '#690CB0',
|
||||
children: <Button>Pick a color</Button>,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithColorSwatch: Story = {
|
||||
args: {
|
||||
defaultValue: '#1976D2',
|
||||
children: (
|
||||
<Button style={{ padding: 4 }}>
|
||||
<ColorSwatch
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomColorSet: Story = {
|
||||
args: {
|
||||
defaultValue: '#FF0000',
|
||||
columns: 4,
|
||||
colorset: [
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FFFF00',
|
||||
'#FF00FF',
|
||||
'#00FFFF',
|
||||
'#FFA500',
|
||||
'#800080',
|
||||
],
|
||||
children: <Button>Custom Colors</Button>,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'ColorPicker with a custom color set and different column layout.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
args: {
|
||||
children: <Button>Pick a color</Button>,
|
||||
},
|
||||
render: () => {
|
||||
const [color, setColor] = useState('#388E3C');
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<ColorPicker value={color} onChange={c => setColor(c.toString('hex'))}>
|
||||
<Button style={{ padding: 4 }}>
|
||||
<ColorSwatch
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</ColorPicker>
|
||||
<span>Selected: {color}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Controlled ColorPicker with external state management.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import { type ChangeEvent, type ReactNode } from 'react';
|
||||
import {
|
||||
ColorPicker as AriaColorPicker,
|
||||
type ColorPickerProps as AriaColorPickerProps,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
ColorSwatchPicker as AriaColorSwatchPicker,
|
||||
ColorField,
|
||||
ColorSwatchPickerItem,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
type ColorSwatchProps,
|
||||
ColorSwatchPicker as AriaColorSwatchPicker,
|
||||
ColorSwatchPickerItem,
|
||||
ColorField,
|
||||
parseColor,
|
||||
type ColorPickerProps as AriaColorPickerProps,
|
||||
type ColorSwatchProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
import {
|
||||
type ComponentProps,
|
||||
useRef,
|
||||
useContext,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import {
|
||||
ComboBox as AriaComboBox,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
ListBoxSection,
|
||||
type ListBoxSectionProps,
|
||||
type ComboBoxProps as AriaComboBoxProps,
|
||||
type ListBoxItemProps,
|
||||
ComboBoxStateContext as AriaComboBoxStateContext,
|
||||
type Key,
|
||||
} from 'react-aria-components';
|
||||
import { type ComboBoxState as AriaComboBoxState } from 'react-stately';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { Popover } from './Popover';
|
||||
import { styles } from './styles';
|
||||
import { theme } from './theme';
|
||||
import { View } from './View';
|
||||
|
||||
const popoverClassName = () =>
|
||||
css({
|
||||
...styles.darkScrollbar,
|
||||
...styles.popover,
|
||||
backgroundColor: theme.menuAutoCompleteBackground,
|
||||
color: theme.menuAutoCompleteText,
|
||||
padding: '5px 0',
|
||||
borderRadius: 4,
|
||||
});
|
||||
|
||||
const listBoxClassName = ({ width }: { width?: number }) =>
|
||||
css({
|
||||
width,
|
||||
minWidth: 200,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
'& [data-focused]': {
|
||||
backgroundColor: theme.menuAutoCompleteBackgroundHover,
|
||||
},
|
||||
});
|
||||
|
||||
type ComboBoxProps<T extends object> = Omit<
|
||||
AriaComboBoxProps<T>,
|
||||
'children'
|
||||
> & {
|
||||
inputPlaceholder?: string;
|
||||
children: ComponentProps<typeof ListBox<T>>['children'];
|
||||
};
|
||||
|
||||
export function ComboBox<T extends object>({
|
||||
children,
|
||||
...props
|
||||
}: ComboBoxProps<T>) {
|
||||
const viewRef = useRef<HTMLDivElement | null>(null);
|
||||
return (
|
||||
<AriaComboBox<T>
|
||||
allowsEmptyCollection
|
||||
allowsCustomValue
|
||||
menuTrigger="focus"
|
||||
{...props}
|
||||
>
|
||||
<View ref={viewRef}>
|
||||
<ComboBoxInput placeholder={props.inputPlaceholder} />
|
||||
</View>
|
||||
<Popover isNonModal className={popoverClassName()}>
|
||||
<ListBox<T>
|
||||
className={listBoxClassName({ width: viewRef.current?.clientWidth })}
|
||||
>
|
||||
{children}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboBoxInputContextValue = {
|
||||
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
|
||||
};
|
||||
|
||||
const ComboBoxInputContext = createContext<ComboBoxInputContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
type ComboBoxInputProviderProps = {
|
||||
children: ReactNode;
|
||||
getFocusedKey?: (state: AriaComboBoxState<unknown>) => Key | null;
|
||||
};
|
||||
|
||||
export function ComboBoxInputProvider({
|
||||
children,
|
||||
getFocusedKey,
|
||||
}: ComboBoxInputProviderProps) {
|
||||
return (
|
||||
<ComboBoxInputContext.Provider value={{ getFocusedKey }}>
|
||||
{children}
|
||||
</ComboBoxInputContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
type ComboBoxInputProps = ComponentProps<typeof Input>;
|
||||
|
||||
function ComboBoxInput({ onKeyUp, ...props }: ComboBoxInputProps) {
|
||||
const state = useContext(AriaComboBoxStateContext);
|
||||
const _onKeyUp = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
state?.revert();
|
||||
}
|
||||
onKeyUp?.(e);
|
||||
};
|
||||
|
||||
const comboBoxInputContext = useContext(ComboBoxInputContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (state && state.inputValue && !state.selectionManager.focusedKey) {
|
||||
const focusedKey: Key | null =
|
||||
(comboBoxInputContext?.getFocusedKey
|
||||
? comboBoxInputContext.getFocusedKey(state)
|
||||
: defaultGetFocusedKey(state)) ?? null;
|
||||
|
||||
state.selectionManager.setFocusedKey(focusedKey);
|
||||
}
|
||||
}, [comboBoxInputContext, state, state?.inputValue]);
|
||||
|
||||
return <Input onKeyUp={_onKeyUp} {...props} />;
|
||||
}
|
||||
|
||||
function defaultGetFocusedKey<T>(state: AriaComboBoxState<T>) {
|
||||
// Focus on the first suggestion item when typing.
|
||||
const keys = Array.from(state.collection.getKeys());
|
||||
return (
|
||||
keys
|
||||
.map(key => state.collection.getItem(key))
|
||||
.find(i => i && i.type === 'item')?.key ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const defaultComboBoxSectionClassName = () =>
|
||||
css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
'& header': {
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
paddingLeft: 10,
|
||||
color: theme.menuAutoCompleteTextHeader,
|
||||
},
|
||||
});
|
||||
|
||||
type ComboBoxSectionProps<T extends object> = ListBoxSectionProps<T>;
|
||||
|
||||
export function ComboBoxSection<T extends object>({
|
||||
className,
|
||||
...props
|
||||
}: ComboBoxSectionProps<T>) {
|
||||
return (
|
||||
<ListBoxSection
|
||||
className={cx(defaultComboBoxSectionClassName(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const defaultComboBoxItemClassName = () =>
|
||||
css({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: 5,
|
||||
paddingBottom: 5,
|
||||
paddingLeft: 20,
|
||||
});
|
||||
|
||||
type ComboBoxItemProps = ListBoxItemProps;
|
||||
|
||||
export function ComboBoxItem({ className, ...props }: ComboBoxItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps =>
|
||||
cx(defaultComboBoxItemClassName(), className(renderProps))
|
||||
: cx(defaultComboBoxItemClassName(), className)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
90
packages/component-library/src/FormError.stories.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { FormError } from './FormError';
|
||||
import { Input } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'FormError',
|
||||
component: FormError,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FormError>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'This field is required',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'FormError displays validation error messages in red text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InFormContext: Story = {
|
||||
render: () => (
|
||||
<View
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 5, width: 250 }}
|
||||
>
|
||||
<Input placeholder="Email address" style={{ borderColor: 'red' }} />
|
||||
<FormError>Please enter a valid email address</FormError>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'FormError displayed below an input field with validation error.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleErrors: Story = {
|
||||
render: () => (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<FormError>Password must be at least 8 characters</FormError>
|
||||
<FormError>Password must contain a number</FormError>
|
||||
<FormError>Password must contain a special character</FormError>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Multiple FormError components for displaying several validation errors.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyle: Story = {
|
||||
args: {
|
||||
children: 'Custom styled error message',
|
||||
style: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
padding: 10,
|
||||
backgroundColor: '#ffebee',
|
||||
borderRadius: 4,
|
||||
border: '1px solid red',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongErrorMessage: Story = {
|
||||
args: {
|
||||
children:
|
||||
'This is a longer error message that explains the validation issue in more detail. Please correct the input and try again.',
|
||||
style: { maxWidth: 300 },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, type CSSProperties } from 'react';
|
||||
import { type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import { View } from './View';
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@ import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
type InitialFocusProps<T extends HTMLElement> = {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithRef,
|
||||
type KeyboardEvent,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import { Input as ReactAriaInput } from 'react-aria-components';
|
||||
|
||||
|
||||
27
packages/component-library/src/Introduction.mdx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Introduction" />
|
||||
|
||||
# Actual Budget Component Library
|
||||
|
||||
Welcome to the **Actual Budget Component Library**. Explore our UI components, see how they look across different themes, and learn how to use them in your code.
|
||||
|
||||
### What you can do here
|
||||
|
||||
- ✨ **Browse components** in the sidebar
|
||||
- 🎨 **Switch themes** using the toolbar above
|
||||
- 📚 **Read documentation** and see code examples
|
||||
- 🔍 **Test variations** and component states
|
||||
- ♿ **Check accessibility** compliance
|
||||
|
||||
### Getting Started
|
||||
|
||||
Select a component from the sidebar to explore its documentation, variants, and interactive controls.
|
||||
|
||||
---
|
||||
|
||||
### Useful Links
|
||||
|
||||
- [Actual Budget Website](https://actualbudget.org)
|
||||
- [Documentation](https://actualbudget.org/docs)
|
||||
- [GitHub Repository](https://github.com/actualbudget/actual)
|
||||
@@ -1,4 +1,4 @@
|
||||
import { forwardRef, type ReactNode, type CSSProperties } from 'react';
|
||||
import { forwardRef, type CSSProperties, type ReactNode } from 'react';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { Text } from './Text';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
type SVGProps,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
@@ -152,7 +152,7 @@ export function Menu<const NameType = string>({
|
||||
<View
|
||||
className={className}
|
||||
style={{ outline: 'none', borderRadius: 4, overflow: 'hidden', ...style }}
|
||||
tabIndex={1}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
innerRef={elRef}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps, useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, type ComponentProps } from 'react';
|
||||
import { Popover as ReactAriaPopover } from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, {
|
||||
type HTMLProps,
|
||||
type Ref,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type SVGProps, useState } from 'react';
|
||||
import React, { useState, type SVGProps } from 'react';
|
||||
|
||||
export const SvgLoading = (props: SVGProps<SVGSVGElement>) => {
|
||||
const { color = 'currentColor' } = props;
|
||||
|
||||
@@ -91,7 +91,10 @@ export const styles: Record<string, any> = {
|
||||
},
|
||||
shadowLarge,
|
||||
tnum: {
|
||||
fontFeatureSettings: '"tnum"',
|
||||
// tnum: Tabular numbers
|
||||
// ss01: Open digits
|
||||
// ss04: Disambiguation w/o zero
|
||||
fontFeatureSettings: '"tnum", "ss01", "ss04"',
|
||||
},
|
||||
notFixed: { fontFeatureSettings: '' },
|
||||
text: {
|
||||
@@ -159,4 +162,11 @@ export const styles: Record<string, any> = {
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
tableContainer: {
|
||||
flex: 1,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
borderTopLeftRadius: 6,
|
||||
borderTopRightRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,6 +13,9 @@ export const theme = {
|
||||
pageTextPositive: 'var(--color-pageTextPositive)',
|
||||
pageTextLink: 'var(--color-pageTextLink)',
|
||||
pageTextLinkLight: 'var(--color-pageTextLinkLight)',
|
||||
numberPositive: 'var(--color-numberPositive)',
|
||||
numberNegative: 'var(--color-numberNegative)',
|
||||
numberNeutral: 'var(--color-numberNeutral)',
|
||||
cardBackground: 'var(--color-cardBackground)',
|
||||
cardBorder: 'var(--color-cardBorder)',
|
||||
cardShadow: 'var(--color-cardShadow)',
|
||||
@@ -43,6 +46,7 @@ export const theme = {
|
||||
sidebarItemBackgroundHover: 'var(--color-sidebarItemBackgroundHover)',
|
||||
sidebarItemText: 'var(--color-sidebarItemText)',
|
||||
sidebarItemTextSelected: 'var(--color-sidebarItemTextSelected)',
|
||||
sidebarBudgetName: 'var(--color-sidebarBudgetName)',
|
||||
menuBackground: 'var(--color-menuBackground)',
|
||||
menuItemBackground: 'var(--color-menuItemBackground)',
|
||||
menuItemBackgroundHover: 'var(--color-menuItemBackgroundHover)',
|
||||
@@ -186,6 +190,19 @@ export const theme = {
|
||||
reportsGray: 'var(--color-reportsGray)',
|
||||
reportsLabel: 'var(--color-reportsLabel)',
|
||||
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
|
||||
reportsChartFill: 'var(--color-reportsChartFill)',
|
||||
reportsNumberPositive: 'var(--color-reportsNumberPositive)',
|
||||
reportsNumberNegative: 'var(--color-reportsNumberNegative)',
|
||||
reportsNumberNeutral: 'var(--color-reportsNumberNeutral)',
|
||||
budgetNumberPositive: 'var(--color-budgetNumberPositive)',
|
||||
budgetNumberNegative: 'var(--color-budgetNumberNegative)',
|
||||
budgetNumberNeutral: 'var(--color-budgetNumberNeutral)',
|
||||
budgetNumberZero: 'var(--color-budgetNumberZero)',
|
||||
toBudgetPositive: 'var(--color-toBudgetPositive)',
|
||||
toBudgetZero: 'var(--color-toBudgetZero)',
|
||||
toBudgetNegative: 'var(--color-toBudgetNegative)',
|
||||
templateNumberFunded: 'var(--color-templateNumberFunded)',
|
||||
templateNumberUnderFunded: 'var(--color-templateNumberUnderFunded)',
|
||||
noteTagBackground: 'var(--color-noteTagBackground)',
|
||||
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
|
||||
noteTagDefault: 'var(--color-noteTagDefault)',
|
||||
@@ -201,4 +218,5 @@ export const theme = {
|
||||
tooltipBackground: 'var(--color-tooltipBackground)',
|
||||
tooltipBorder: 'var(--color-tooltipBorder)',
|
||||
calendarCellBackground: 'var(--color-calendarCellBackground)',
|
||||
overlayBackground: 'var(--color-overlayBackground)',
|
||||
};
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.9"
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +65,10 @@ Run manually:
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash
|
||||
|
||||
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.56.0-jammy /bin/bash
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash
|
||||
|
||||
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
|
||||
# Use the ip and port noted earlier
|
||||
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 67 KiB |