mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
448da13cf5 | ||
|
|
41679235be | ||
|
|
73fa068fe9 | ||
|
|
1fe588c143 | ||
|
|
edce092ae8 | ||
|
|
77411394f6 | ||
|
|
235d94478f | ||
|
|
7e0edd43ec | ||
|
|
fdf5c8d0a9 | ||
|
|
a8ec84ceac | ||
|
|
b727124603 | ||
|
|
8bb7f207f2 | ||
|
|
6e0c15eb12 | ||
|
|
4e2cec2c7a | ||
|
|
078603cadf | ||
|
|
b3a86b5392 | ||
|
|
295a565e55 | ||
|
|
387c8fce16 | ||
|
|
c7ebfd8ad4 | ||
|
|
e1f834371b | ||
|
|
4caee99955 | ||
|
|
286d05d187 | ||
|
|
cf05a7ea01 | ||
|
|
b373b612a4 | ||
|
|
3797cff716 | ||
|
|
9e2793d413 | ||
|
|
3201819df9 | ||
|
|
eca50f28b0 | ||
|
|
c82ee91b12 | ||
|
|
cb8ff337dc | ||
|
|
c37a5a02aa | ||
|
|
f9e09ca59b | ||
|
|
8081b8829e | ||
|
|
f2f79d378c | ||
|
|
c5cca67399 | ||
|
|
eabf09587f | ||
|
|
6022929551 | ||
|
|
e65429497d | ||
|
|
3758d72b65 | ||
|
|
032d10ac42 | ||
|
|
f97a89dc28 | ||
|
|
a4bd301ec6 | ||
|
|
18072e1d8b | ||
|
|
a1e0b3f45d | ||
|
|
0b361e45b4 | ||
|
|
b3052dda05 | ||
|
|
31a027fc64 | ||
|
|
cfc18c240a | ||
|
|
a68b2acac3 | ||
|
|
a25be5c95c | ||
|
|
bf1947a119 | ||
|
|
37ad0ed563 | ||
|
|
89d68ea2f8 | ||
|
|
1ad7b6f781 | ||
|
|
fd9ee868a6 | ||
|
|
0de44af1de | ||
|
|
cf58712bf1 | ||
|
|
6460af3de4 | ||
|
|
ce890faeeb | ||
|
|
848b86cd59 | ||
|
|
ec22923f18 | ||
|
|
27402ee2b3 | ||
|
|
0472211925 | ||
|
|
a38104244a | ||
|
|
77b848ca84 | ||
|
|
5179ac7c2d | ||
|
|
2bb5a861c1 | ||
|
|
bc32f4fcde | ||
|
|
848eaadb0f | ||
|
|
5ac2947342 | ||
|
|
da0154a41b | ||
|
|
180a38890c | ||
|
|
d3f2f1f7ae | ||
|
|
c7efb61b84 | ||
|
|
d605d59d01 | ||
|
|
f7227f4e62 | ||
|
|
253530e239 | ||
|
|
e8f6ceeb98 | ||
|
|
c031d9aa4f | ||
|
|
d4e25f4047 | ||
|
|
a7f96a59fa | ||
|
|
8af64ddd5e | ||
|
|
26dbb219aa | ||
|
|
c6656a2815 | ||
|
|
6358345286 | ||
|
|
5943ae3df5 | ||
|
|
7e8a118411 | ||
|
|
465608c76b | ||
|
|
5062fa78a8 | ||
|
|
e178396e48 | ||
|
|
09d85bbdc5 | ||
|
|
a0378c10a9 | ||
|
|
ca944baee5 | ||
|
|
cb5237359c | ||
|
|
68bb33e5e6 | ||
|
|
cf5fe67e7b | ||
|
|
f8b4e87a67 | ||
|
|
8ae90a7ad1 | ||
|
|
6f7af102a6 | ||
|
|
96a3128305 | ||
|
|
003efecc23 | ||
|
|
155e4df219 | ||
|
|
67d6592333 | ||
|
|
7fa9fa900b | ||
|
|
9798c26462 | ||
|
|
37a7d0eccd | ||
|
|
e3e4b13d2b | ||
|
|
138ea810d6 | ||
|
|
07ff514c12 | ||
|
|
2ca352aaa7 | ||
|
|
078da08ad5 | ||
|
|
edcf893a27 | ||
|
|
38a72656df | ||
|
|
24f698910a | ||
|
|
cdaf06abee | ||
|
|
c8aa0cf1d3 | ||
|
|
84cebed20b | ||
|
|
0c3b54ee7d | ||
|
|
eb9f9b3a73 | ||
|
|
5c31aa03ba | ||
|
|
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 |
@@ -13,8 +13,6 @@ reviews:
|
||||
mode: off
|
||||
enabled: false
|
||||
labeling_instructions:
|
||||
- label: 'suspect ai generated'
|
||||
instructions: 'This issue or PR is suspected to be generated by AI.'
|
||||
- label: 'API'
|
||||
instructions: 'This issue or PR updates the API in `packages/api`.'
|
||||
- label: 'documentation'
|
||||
|
||||
74
.cursor/rules/pr-and-commit.mdc
Normal file
74
.cursor/rules/pr-and-commit.mdc
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
description: Rules for AI-generated commits and pull requests
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# PR and Commit Rules for AI Agents
|
||||
|
||||
Canonical source: `.github/agents/pr-and-commit-rules.md`
|
||||
|
||||
## Commit Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Git Safety Rules
|
||||
|
||||
- **Never** update git config
|
||||
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
|
||||
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
|
||||
- **Never** force push to `main`/`master`
|
||||
- **Never** commit unless explicitly asked by the user
|
||||
|
||||
## Pre-Commit Quality Checklist
|
||||
|
||||
Before committing, ensure all of the following:
|
||||
|
||||
- [ ] Commit message is prefixed with `[AI]`
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Labels
|
||||
|
||||
Add the **"AI generated"** label to all AI-created pull requests.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese** (简体中文).
|
||||
|
||||
## Quick-Reference Workflow
|
||||
|
||||
1. Make your changes
|
||||
2. Run `yarn typecheck` — fix any errors
|
||||
3. Run `yarn lint:fix` — fix any remaining lint errors
|
||||
4. Run relevant tests (`yarn test` for all, or workspace-specific)
|
||||
5. Stage files and commit with `[AI]` prefix — do not skip hooks
|
||||
6. When creating a PR:
|
||||
- Use `[AI]` prefix in the title
|
||||
- Add the `"AI generated"` label
|
||||
- Leave the PR template blank (do not fill it in)
|
||||
57
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
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
|
||||
|
||||
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
20
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1 +1,21 @@
|
||||
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
|
||||
|
||||
## Description
|
||||
|
||||
<!-- What does this PR do? Why is it needed? Please give context on the "why?": why do we need this change? What problem is it solving for you?-->
|
||||
|
||||
## Related issue(s)
|
||||
|
||||
<!-- e.g. Fixes #123, Relates to #456 -->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- What did you test? How can we reproduce the issue you are fixing or how can we test the feature you built? -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Release notes added (see link above)
|
||||
- [ ] No obvious regressions in affected areas
|
||||
- [ ] Self-review has been performed - I understand what each change in the code does and why it is needed
|
||||
|
||||
<!--- actual-bot-sections --->
|
||||
|
||||
@@ -74,4 +74,4 @@ async function checkReleaseNotesExists() {
|
||||
}
|
||||
}
|
||||
|
||||
checkReleaseNotesExists();
|
||||
void checkReleaseNotesExists();
|
||||
|
||||
@@ -74,4 +74,4 @@ async function commentOnPR() {
|
||||
}
|
||||
}
|
||||
|
||||
commentOnPR();
|
||||
void commentOnPR();
|
||||
|
||||
@@ -94,4 +94,4 @@ ${summaryData.summary}
|
||||
}
|
||||
}
|
||||
|
||||
createReleaseNotesFile();
|
||||
void createReleaseNotesFile();
|
||||
|
||||
@@ -37,12 +37,14 @@ async function getPRDetails() {
|
||||
console.log('- PR Author:', pr.user.login);
|
||||
console.log('- PR Title:', pr.title);
|
||||
console.log('- Base Branch:', pr.base.ref);
|
||||
console.log('- Head Branch:', pr.head.ref);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
title: pr.title,
|
||||
baseBranch: pr.base.ref,
|
||||
headBranch: pr.head.ref,
|
||||
};
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
|
||||
2
.github/actions/docs-spelling/expect.txt
vendored
2
.github/actions/docs-spelling/expect.txt
vendored
@@ -31,6 +31,7 @@ CAGLPTPL
|
||||
Caixa
|
||||
CAMT
|
||||
cashflow
|
||||
Catppuccin
|
||||
Cetelem
|
||||
cimode
|
||||
Citi
|
||||
@@ -109,6 +110,7 @@ KBCBE
|
||||
Keycloak
|
||||
Khurozov
|
||||
KORT
|
||||
KRW
|
||||
Kreditbank
|
||||
lage
|
||||
LHV
|
||||
|
||||
3
.github/actions/docs-spelling/patterns.txt
vendored
3
.github/actions/docs-spelling/patterns.txt
vendored
@@ -79,3 +79,6 @@
|
||||
|
||||
# allowlist specific non-English words with non-ASCII characters
|
||||
\b(Länsförsäkringar|München|Złoty)\b
|
||||
|
||||
# allowlist specific proper nouns
|
||||
\b(CodeRabbit)\b
|
||||
|
||||
70
.github/agents/pr-and-commit-rules.md
vendored
Normal file
70
.github/agents/pr-and-commit-rules.md
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
# PR and Commit Rules for AI Agents
|
||||
|
||||
This is the single source of truth for all commit and pull request rules that AI agents must follow when working with Actual Budget.
|
||||
|
||||
## Commit Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL commit messages MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
- `Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Git Safety Rules
|
||||
|
||||
- **Never** update git config
|
||||
- **Never** run destructive git operations (force push, hard reset) unless the user explicitly requests it
|
||||
- **Never** skip hooks (`--no-verify`, `--no-gpg-sign`)
|
||||
- **Never** force push to `main`/`master`
|
||||
- **Never** commit unless explicitly asked by the user
|
||||
|
||||
## Pre-Commit Quality Checklist
|
||||
|
||||
Before committing, ensure all of the following:
|
||||
|
||||
- [ ] Commit message is prefixed with `[AI]`
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
- [ ] User-facing strings are translated
|
||||
- [ ] Code style conventions followed (see `AGENTS.md` for full style guide)
|
||||
|
||||
## Pull Request Rules
|
||||
|
||||
### [AI] Prefix Requirement
|
||||
|
||||
**ALL pull request titles MUST be prefixed with `[AI]`.** This is a mandatory requirement with no exceptions.
|
||||
|
||||
**Examples:**
|
||||
|
||||
- `[AI] Fix type error in account validation`
|
||||
- `[AI] Add support for new transaction categories`
|
||||
- `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
|
||||
|
||||
### Labels
|
||||
|
||||
Add the **"AI generated"** label to all AI-created pull requests. This helps maintainers understand the nature of the contribution.
|
||||
|
||||
### PR Template: Do Not Fill In
|
||||
|
||||
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. Humans are expected to fill in the Description, Related issue(s), Testing, and Checklist sections.
|
||||
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
|
||||
|
||||
## Quick-Reference Workflow
|
||||
|
||||
Follow these steps when committing and creating PRs:
|
||||
|
||||
1. Make your changes
|
||||
2. Run `yarn typecheck` — fix any errors
|
||||
3. Run `yarn lint:fix` — fix any remaining lint errors
|
||||
4. Run relevant tests (`yarn test` for all, or workspace-specific)
|
||||
5. Stage files and commit with `[AI]` prefix — do not skip hooks
|
||||
6. When creating a PR:
|
||||
- Use `[AI]` prefix in the title
|
||||
- Add the `"AI generated"` label
|
||||
- Leave the PR template blank (do not fill it in)
|
||||
165
.github/scripts/count-points.mjs
vendored
165
.github/scripts/count-points.mjs
vendored
@@ -8,6 +8,13 @@ const CONFIG = {
|
||||
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
|
||||
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
|
||||
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
|
||||
PR_CONTRIBUTION_POINTS: [
|
||||
{ categories: ['Features'], points: 2 },
|
||||
{ categories: ['Enhancements'], points: 2 },
|
||||
{ categories: ['Bugfixes', 'Bugfix'], points: 3 },
|
||||
{ categories: ['Maintenance'], points: 2 },
|
||||
{ categories: ['Unknown'], points: 2 },
|
||||
],
|
||||
// Point tiers for code changes (non-docs)
|
||||
CODE_PR_REVIEW_POINT_TIERS: [
|
||||
{ minChanges: 500, points: 8 },
|
||||
@@ -31,6 +38,122 @@ const CONFIG = {
|
||||
DOCS_FILES_PATTERN: 'packages/docs/**/*',
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse category from release notes file content.
|
||||
* @param {string} content - The content of the release notes file.
|
||||
* @returns {string|null} The category or null if not found.
|
||||
*/
|
||||
function parseReleaseNotesCategory(content) {
|
||||
if (!content) return null;
|
||||
|
||||
// Extract YAML front matter
|
||||
const frontMatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!frontMatterMatch) return null;
|
||||
|
||||
// Extract category from front matter
|
||||
const categoryMatch = frontMatterMatch[1].match(/^category:\s*(.+)$/m);
|
||||
if (!categoryMatch) return null;
|
||||
|
||||
return categoryMatch[1].trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last commit SHA on or before a given date.
|
||||
* @param {Octokit} octokit - The Octokit instance.
|
||||
* @param {string} owner - Repository owner.
|
||||
* @param {string} repo - Repository name.
|
||||
* @param {Date} beforeDate - The date to find the last commit before.
|
||||
* @returns {Promise<string|null>} The commit SHA or null if not found.
|
||||
*/
|
||||
async function getLastCommitBeforeDate(octokit, owner, repo, beforeDate) {
|
||||
try {
|
||||
// Get the default branch from the repository
|
||||
const { data: repoData } = await octokit.repos.get({ owner, repo });
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
const { data: commits } = await octokit.repos.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
sha: defaultBranch,
|
||||
until: beforeDate.toISOString(),
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
if (commits.length > 0) {
|
||||
return commits[0].sha;
|
||||
}
|
||||
} catch {
|
||||
// If error occurs, return null to fall back to default branch
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the category and points for a PR by reading its release notes file.
|
||||
* @param {Octokit} octokit - The Octokit instance.
|
||||
* @param {string} owner - Repository owner.
|
||||
* @param {string} repo - Repository name.
|
||||
* @param {number} prNumber - PR number.
|
||||
* @param {Date} monthEnd - The end date of the month to use as base revision.
|
||||
* @returns {Promise<Object>} Object with category and points, or null if error.
|
||||
*/
|
||||
async function getPRCategoryAndPoints(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
prNumber,
|
||||
monthEnd,
|
||||
) {
|
||||
const releaseNotesPath = `upcoming-release-notes/${prNumber}.md`;
|
||||
|
||||
try {
|
||||
// Get the last commit of the month to use as base revision
|
||||
const commitSha = await getLastCommitBeforeDate(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
// Try to read the release notes file from the last commit of the month
|
||||
const { data: fileContent } = await octokit.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path: releaseNotesPath,
|
||||
ref: commitSha || undefined, // Use commit SHA if available, otherwise default branch
|
||||
});
|
||||
|
||||
if (fileContent.content) {
|
||||
// Decode base64 content
|
||||
const content = Buffer.from(fileContent.content, 'base64').toString(
|
||||
'utf-8',
|
||||
);
|
||||
const category = parseReleaseNotesCategory(content);
|
||||
const tier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes(category),
|
||||
);
|
||||
|
||||
if (tier) {
|
||||
return {
|
||||
category,
|
||||
points: tier.points,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
const unknownTier = CONFIG.PR_CONTRIBUTION_POINTS.find(e =>
|
||||
e.categories.includes('Unknown'),
|
||||
);
|
||||
return {
|
||||
category: 'Unknown',
|
||||
points: unknownTier.points,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start and end dates for the last month.
|
||||
* @returns {Object} An object containing the start and end dates.
|
||||
@@ -89,6 +212,7 @@ async function countContributorPoints() {
|
||||
{
|
||||
codeReviews: [], // Will store objects with PR number and points for main repo changes
|
||||
docsReviews: [], // Will store objects with PR number and points for docs changes
|
||||
prContributions: [], // Will store objects with PR number, category, and points for PR author contributions
|
||||
labelRemovals: [],
|
||||
issueClosings: [],
|
||||
points: 0,
|
||||
@@ -202,6 +326,28 @@ async function countContributorPoints() {
|
||||
mergerStats.points += CONFIG.POINTS_PER_RELEASE_PR;
|
||||
}
|
||||
} else {
|
||||
// Award points to PR author if they are a core maintainer
|
||||
const prAuthor = pr.user?.login;
|
||||
if (prAuthor && orgMemberLogins.has(prAuthor)) {
|
||||
const categoryAndPoints = await getPRCategoryAndPoints(
|
||||
octokit,
|
||||
owner,
|
||||
repo,
|
||||
pr.number,
|
||||
until,
|
||||
);
|
||||
|
||||
if (categoryAndPoints) {
|
||||
const authorStats = stats.get(prAuthor);
|
||||
authorStats.prContributions.push({
|
||||
pr: pr.number.toString(),
|
||||
category: categoryAndPoints.category,
|
||||
points: categoryAndPoints.points,
|
||||
});
|
||||
authorStats.points += categoryAndPoints.points;
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueReviewers = new Set();
|
||||
reviews.data.forEach(review => {
|
||||
if (
|
||||
@@ -293,7 +439,7 @@ async function countContributorPoints() {
|
||||
// Print all statistics
|
||||
printStats(
|
||||
'Code Review Statistics',
|
||||
stats => stats.codeReviews.length,
|
||||
stats => stats.codeReviews.reduce((sum, r) => sum + r.points, 0),
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
@@ -308,7 +454,7 @@ async function countContributorPoints() {
|
||||
|
||||
printStats(
|
||||
'Docs Review Statistics',
|
||||
stats => stats.docsReviews.length,
|
||||
stats => stats.docsReviews.reduce((sum, r) => sum + r.points, 0),
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
@@ -316,16 +462,27 @@ async function countContributorPoints() {
|
||||
.join(', ')})`,
|
||||
);
|
||||
|
||||
printStats(
|
||||
'PR Contribution Statistics',
|
||||
stats => stats.prContributions.reduce((sum, r) => sum + r.points, 0),
|
||||
(user, count) =>
|
||||
`${user}: ${count} (PRs: ${stats
|
||||
.get(user)
|
||||
.prContributions.map(r => `#${r.pr} (${r.points}pts - ${r.category})`)
|
||||
.join(', ')})`,
|
||||
);
|
||||
|
||||
printStats(
|
||||
'"Needs Triage" Label Removal Statistics',
|
||||
stats => stats.labelRemovals.length,
|
||||
stats => stats.labelRemovals.length * CONFIG.POINTS_PER_ISSUE_TRIAGE_ACTION,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
|
||||
);
|
||||
|
||||
printStats(
|
||||
'Issue Closing Statistics',
|
||||
stats => stats.issueClosings.length,
|
||||
stats =>
|
||||
stats.issueClosings.length * CONFIG.POINTS_PER_ISSUE_CLOSING_ACTION,
|
||||
(user, count) =>
|
||||
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
|
||||
);
|
||||
|
||||
19
.github/workflows/ai-generated-release-notes.yml
vendored
19
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -41,21 +41,12 @@ jobs:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if PR targets master branch
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
|
||||
id: check-base-branch
|
||||
run: |
|
||||
BASE_BRANCH=$(echo '${{ steps.pr-details.outputs.result }}' | jq -r '.baseBranch')
|
||||
echo "Base branch: $BASE_BRANCH"
|
||||
if [ "$BASE_BRANCH" = "master" ]; then
|
||||
echo "targets_master=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "targets_master=false" >> $GITHUB_OUTPUT
|
||||
echo "PR does not target master branch, skipping release notes generation"
|
||||
fi
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null' && steps.check-base-branch.outputs.targets_master == 'true'
|
||||
if: >-
|
||||
steps.check-first-comment.outputs.result == 'true' &&
|
||||
steps.pr-details.outputs.result != 'null' &&
|
||||
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
|
||||
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
|
||||
7
.github/workflows/check.yml
vendored
7
.github/workflows/check.yml
vendored
@@ -60,8 +60,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: 22
|
||||
download-translations: 'false'
|
||||
- name: Check migrations
|
||||
run: node ./.github/actions/check-migrations.js
|
||||
run: yarn workspace @actual-app/ci-actions tsx bin/check-migrations.ts
|
||||
|
||||
4
.github/workflows/docker-edge.yml
vendored
4
.github/workflows/docker-edge.yml
vendored
@@ -87,8 +87,8 @@ jobs:
|
||||
- name: Test that the docker image boots
|
||||
run: |
|
||||
docker run --detach --network=host actualbudget/actual-server-testing
|
||||
sleep 5
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
|
||||
sleep 10
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
|
||||
|
||||
# This will use the cache from the earlier build step and not rebuild the image
|
||||
# https://docs.docker.com/build/ci/github-actions/test-before-push/
|
||||
|
||||
8
.github/workflows/e2e-test.yml
vendored
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.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-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.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-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.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-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.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
|
||||
49
.github/workflows/electron-master.yml
vendored
49
.github/workflows/electron-master.yml
vendored
@@ -156,52 +156,3 @@ jobs:
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
|
||||
publish-flathub:
|
||||
needs: build
|
||||
runs-on: ubuntu-22.04
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
with:
|
||||
name: actual-electron-ubuntu-22.04
|
||||
|
||||
- name: Calculate AppImage SHA256
|
||||
id: appimage_sha256
|
||||
run: |
|
||||
APPIMAGE_X64_SHA256=$(sha256sum Actual-linux-x86_64.AppImage | awk '{ print $1 }')
|
||||
APPIMAGE_ARM64_SHA256=$(sha256sum Actual-linux-arm64.AppImage | awk '{ print $1 }')
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
|
||||
- name: Update manifest with new SHA256
|
||||
run: |
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${{ needs.build.outputs.version }}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
branch: 'release/${{ needs.build.outputs.version }}'
|
||||
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ needs.build.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw' # The core team that have accepted the collaborator access to the Flathub repo
|
||||
|
||||
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
Normal file
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
|
||||
# so it can be merged during a freeze. Uses pull_request_target so the workflow runs in
|
||||
# the base repo and has access to MERGEFREEZE_ACCESS_TOKEN for fork PRs; it does not
|
||||
# checkout or run any PR code. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
|
||||
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
|
||||
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
|
||||
|
||||
name: Merge Freeze – add PR to unblocked list
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
unfreeze:
|
||||
if: ${{ github.event.label.name == 'unfreeze' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
concurrency:
|
||||
group: merge-freeze-unfreeze-${{ github.ref }}-labels
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: POST to Merge Freeze – add PR to unblocked list
|
||||
env:
|
||||
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
USER_NAME: ${{ github.actor }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
|
||||
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
|
||||
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
|
||||
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
|
||||
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
@@ -1,25 +0,0 @@
|
||||
name: Remove 'suspect ai generated' label when 'AI generated' is present
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
remove-suspect-label:
|
||||
if: >-
|
||||
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
|
||||
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
|
||||
runs-on: ubuntu-slim
|
||||
steps:
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: 'suspect ai generated'
|
||||
});
|
||||
126
.github/workflows/publish-flathub.yml
vendored
Normal file
126
.github/workflows/publish-flathub.yml
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
name: Publish Flathub
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v25.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-flathub
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish-flathub:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: resolve_version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||
TAG="$RELEASE_TAG"
|
||||
else
|
||||
TAG="$INPUT_TAG"
|
||||
fi
|
||||
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "::error::No tag provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag=$TAG version=$VERSION"
|
||||
|
||||
- name: Verify release assets exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
TAG="${{ steps.resolve_version.outputs.tag }}"
|
||||
|
||||
echo "Checking release assets for tag $TAG..."
|
||||
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
|
||||
|
||||
echo "Found assets:"
|
||||
echo "$ASSETS"
|
||||
|
||||
if ! echo "$ASSETS" | grep -qx "Actual-linux-x86_64.AppImage"; then
|
||||
echo "::error::Missing asset: Actual-linux-x86_64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$ASSETS" | grep -qx "Actual-linux-arm64.AppImage"; then
|
||||
echo "::error::Missing asset: Actual-linux-arm64.AppImage"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "All required AppImage assets found."
|
||||
|
||||
- name: Calculate AppImage SHA256 (streamed)
|
||||
run: |
|
||||
VERSION="${{ steps.resolve_version.outputs.version }}"
|
||||
BASE_URL="https://github.com/${{ github.repository }}/releases/download/v${VERSION}"
|
||||
|
||||
echo "Streaming x86_64 AppImage to compute SHA256..."
|
||||
APPIMAGE_X64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-x86_64.AppImage" | sha256sum | awk '{ print $1 }')
|
||||
echo "x86_64 SHA256: $APPIMAGE_X64_SHA256"
|
||||
|
||||
echo "Streaming arm64 AppImage to compute SHA256..."
|
||||
APPIMAGE_ARM64_SHA256=$(curl -fSL "${BASE_URL}/Actual-linux-arm64.AppImage" | sha256sum | awk '{ print $1 }')
|
||||
echo "arm64 SHA256: $APPIMAGE_ARM64_SHA256"
|
||||
|
||||
echo "APPIMAGE_X64_SHA256=$APPIMAGE_X64_SHA256" >> "$GITHUB_ENV"
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
|
||||
- name: Update manifest with new version
|
||||
run: |
|
||||
VERSION="${{ steps.resolve_version.outputs.version }}"
|
||||
|
||||
# Replace x86_64 entry
|
||||
sed -i "/x86_64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_X64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/x86_64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-x86_64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
# Replace arm64 entry
|
||||
sed -i "/arm64.AppImage/{n;s|sha256:.*|sha256: ${{ env.APPIMAGE_ARM64_SHA256 }}|}" com.actualbudget.actual.yml
|
||||
sed -i "/arm64.AppImage/s|url:.*|url: https://github.com/actualbudget/actual/releases/download/v${VERSION}/Actual-linux-arm64.AppImage|" com.actualbudget.actual.yml
|
||||
|
||||
echo "Updated manifest:"
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
branch: 'release/${{ steps.resolve_version.outputs.version }}'
|
||||
title: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
body: |
|
||||
This PR updates the Actual desktop flatpak to version ${{ steps.resolve_version.outputs.version }}.
|
||||
|
||||
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.resolve_version.outputs.version }})
|
||||
reviewers: 'jfdoming,MatissJanis,youngcw'
|
||||
2
.github/workflows/vrt-update-generate.yml
vendored
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.57.0-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,7 +33,9 @@ packages/desktop-electron/dist
|
||||
packages/desktop-electron/loot-core
|
||||
packages/desktop-client/service-worker
|
||||
packages/plugins-service/dist
|
||||
packages/component-library/dist
|
||||
packages/loot-core/lib-dist
|
||||
**/.tsbuildinfo
|
||||
packages/sync-server/coverage
|
||||
bundle.desktop.js
|
||||
bundle.desktop.js.map
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "react",
|
||||
"elementNamePattern": ["react"]
|
||||
"elementNamePattern": ["react", "react-dom/*", "react-*"]
|
||||
},
|
||||
{
|
||||
"groupName": "loot-core",
|
||||
"elementNamePattern": ["loot-core"]
|
||||
"elementNamePattern": ["loot-core/**"]
|
||||
},
|
||||
{
|
||||
"groupName": "desktop-client",
|
||||
"elementNamePattern": ["@desktop-client"]
|
||||
"elementNamePattern": ["@desktop-client/**"]
|
||||
}
|
||||
],
|
||||
"newlinesBetween": true
|
||||
|
||||
253
.oxlintrc.json
253
.oxlintrc.json
@@ -20,72 +20,72 @@
|
||||
"rules": {
|
||||
// Import sorting
|
||||
"perfectionist/sort-named-imports": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"groups": ["value-import", "type-import"]
|
||||
}
|
||||
],
|
||||
|
||||
// Actual rules
|
||||
"actual/typography": "warn",
|
||||
"actual/typography": "error",
|
||||
"actual/no-untranslated-strings": "error",
|
||||
"actual/prefer-trans-over-t": "error",
|
||||
"actual/prefer-if-statement": "warn",
|
||||
"actual/prefer-if-statement": "error",
|
||||
"actual/prefer-logger-over-console": "error",
|
||||
"actual/object-shorthand-properties": "warn",
|
||||
"actual/prefer-const": "warn",
|
||||
"actual/no-anchor-tag": "warn",
|
||||
"actual/no-react-default-import": "warn",
|
||||
"actual/object-shorthand-properties": "error",
|
||||
"actual/prefer-const": "error",
|
||||
"actual/no-anchor-tag": "error",
|
||||
"actual/no-react-default-import": "error",
|
||||
|
||||
// JSX A11y rules
|
||||
"jsx-a11y/no-autofocus": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/alt-text": "warn",
|
||||
"jsx-a11y/anchor-has-content": "warn",
|
||||
"jsx-a11y/alt-text": "error",
|
||||
"jsx-a11y/anchor-has-content": "error",
|
||||
"jsx-a11y/anchor-is-valid": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"aspects": ["noHref", "invalidHref"]
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "warn",
|
||||
"jsx-a11y/aria-props": "warn",
|
||||
"jsx-a11y/aria-proptypes": "warn",
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
|
||||
"jsx-a11y/aria-props": "error",
|
||||
"jsx-a11y/aria-proptypes": "error",
|
||||
"jsx-a11y/aria-role": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"jsx-a11y/aria-unsupported-elements": "warn",
|
||||
"jsx-a11y/heading-has-content": "warn",
|
||||
"jsx-a11y/iframe-has-title": "warn",
|
||||
"jsx-a11y/img-redundant-alt": "warn",
|
||||
"jsx-a11y/no-access-key": "warn",
|
||||
"jsx-a11y/no-distracting-elements": "warn",
|
||||
"jsx-a11y/no-redundant-roles": "warn",
|
||||
"jsx-a11y/role-has-required-aria-props": "warn",
|
||||
"jsx-a11y/role-supports-aria-props": "warn",
|
||||
"jsx-a11y/scope": "warn",
|
||||
"jsx-a11y/aria-unsupported-elements": "error",
|
||||
"jsx-a11y/heading-has-content": "error",
|
||||
"jsx-a11y/iframe-has-title": "error",
|
||||
"jsx-a11y/img-redundant-alt": "error",
|
||||
"jsx-a11y/no-access-key": "error",
|
||||
"jsx-a11y/no-distracting-elements": "error",
|
||||
"jsx-a11y/no-redundant-roles": "error",
|
||||
"jsx-a11y/role-has-required-aria-props": "error",
|
||||
"jsx-a11y/role-supports-aria-props": "error",
|
||||
"jsx-a11y/scope": "error",
|
||||
|
||||
// Typescript rules
|
||||
"typescript/ban-ts-comment": ["warn"],
|
||||
"typescript/consistent-type-definitions": ["warn", "type"],
|
||||
"typescript/ban-ts-comment": ["error"],
|
||||
"typescript/consistent-type-definitions": ["error", "type"],
|
||||
"typescript/consistent-type-imports": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"typescript/no-implied-eval": "warn",
|
||||
"typescript/no-explicit-any": "warn",
|
||||
"typescript/no-implied-eval": "error",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-restricted-types": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"types": {
|
||||
// forbid FC as superfluous
|
||||
@@ -98,141 +98,146 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"typescript/no-var-requires": "warn",
|
||||
"typescript/no-var-requires": "error",
|
||||
// we want to allow unions such as "{ name: DbAccount['name'] | DbPayee['name'] }"
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
"typescript/await-thenable": "error",
|
||||
"typescript/no-floating-promises": "warn", // TODO: covert to error
|
||||
|
||||
// Import rules
|
||||
"import/consistent-type-specifier-style": "error",
|
||||
"import/first": "error",
|
||||
"import/no-amd": "error",
|
||||
"import/no-default-export": "warn",
|
||||
"import/no-default-export": "error",
|
||||
"import/no-webpack-loader-syntax": "error",
|
||||
"import/no-useless-path-segments": "warn",
|
||||
"import/no-unresolved": "warn",
|
||||
"import/no-unused-modules": "warn",
|
||||
"import/no-useless-path-segments": "error",
|
||||
"import/no-unresolved": "error",
|
||||
"import/no-unused-modules": "error",
|
||||
"import/no-duplicates": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"prefer-inline": true
|
||||
"prefer-inline": false
|
||||
}
|
||||
],
|
||||
|
||||
// React rules
|
||||
"react/exhaustive-deps": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"additionalHooks": "(useQuery|useEffectAfterMount)"
|
||||
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
|
||||
}
|
||||
],
|
||||
"react/jsx-curly-brace-presence": "warn",
|
||||
"react/jsx-curly-brace-presence": "error",
|
||||
"react/jsx-filename-extension": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"extensions": [".jsx", ".tsx"],
|
||||
"allow": "as-needed"
|
||||
}
|
||||
],
|
||||
"react/jsx-no-comment-textnodes": "warn",
|
||||
"react/jsx-no-duplicate-props": "warn",
|
||||
"react/jsx-no-target-blank": "warn",
|
||||
"react/jsx-no-comment-textnodes": "error",
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-target-blank": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-no-useless-fragment": "warn",
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/jsx-pascal-case": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"allowAllCaps": true,
|
||||
"ignore": []
|
||||
}
|
||||
],
|
||||
"react/no-danger-with-children": "warn",
|
||||
"react/no-direct-mutation-state": "warn",
|
||||
"react/no-is-mounted": "warn",
|
||||
"react/no-unstable-nested-components": "warn",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
"react/no-is-mounted": "error",
|
||||
"react/no-unstable-nested-components": "error",
|
||||
"react/require-render-return": "error",
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/self-closing-comp": "warn",
|
||||
"react/style-prop-object": "warn",
|
||||
"react/jsx-boolean-value": "warn",
|
||||
"react/self-closing-comp": "error",
|
||||
"react/style-prop-object": "error",
|
||||
"react/jsx-boolean-value": "error",
|
||||
|
||||
// ESLint rules
|
||||
"eslint/array-callback-return": "warn",
|
||||
"eslint/curly": ["warn", "multi-line", "consistent"],
|
||||
"eslint/array-callback-return": "error",
|
||||
"eslint/curly": ["error", "multi-line", "consistent"],
|
||||
"eslint/default-case": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"commentPattern": "^no default$"
|
||||
}
|
||||
],
|
||||
"eslint/eqeqeq": ["warn", "smart"],
|
||||
"eslint/no-array-constructor": "warn",
|
||||
"eslint/no-caller": "warn",
|
||||
"eslint/no-cond-assign": ["warn", "except-parens"],
|
||||
"eslint/no-const-assign": "warn",
|
||||
"eslint/no-control-regex": "warn",
|
||||
"eslint/no-delete-var": "warn",
|
||||
"eslint/no-dupe-class-members": "warn",
|
||||
"eslint/no-dupe-keys": "warn",
|
||||
"eslint/no-duplicate-case": "warn",
|
||||
"eslint/no-empty-character-class": "warn",
|
||||
"eslint/no-empty-function": "warn",
|
||||
"eslint/no-empty-pattern": "warn",
|
||||
"eslint/no-eval": "warn",
|
||||
"eslint/no-ex-assign": "warn",
|
||||
"eslint/no-extend-native": "warn",
|
||||
"eslint/no-extra-bind": "warn",
|
||||
"eslint/no-extra-label": "warn",
|
||||
"eslint/no-fallthrough": "warn",
|
||||
"eslint/no-func-assign": "warn",
|
||||
"eslint/no-invalid-regexp": "warn",
|
||||
"eslint/no-iterator": "warn",
|
||||
"eslint/no-label-var": "warn",
|
||||
"eslint/no-var": "warn",
|
||||
"eslint/eqeqeq": ["error", "smart"],
|
||||
"eslint/no-array-constructor": "error",
|
||||
"eslint/no-caller": "error",
|
||||
"eslint/no-cond-assign": ["error", "except-parens"],
|
||||
"eslint/no-const-assign": "error",
|
||||
"eslint/no-control-regex": "error",
|
||||
"eslint/no-delete-var": "error",
|
||||
"eslint/no-dupe-class-members": "error",
|
||||
"eslint/no-dupe-keys": "error",
|
||||
"eslint/no-duplicate-case": "error",
|
||||
"eslint/no-empty-character-class": "error",
|
||||
"eslint/no-empty-function": "error",
|
||||
"eslint/no-empty-pattern": "error",
|
||||
"eslint/no-eval": "error",
|
||||
"eslint/no-ex-assign": "error",
|
||||
"eslint/no-extend-native": "error",
|
||||
"eslint/no-extra-bind": "error",
|
||||
"eslint/no-extra-label": "error",
|
||||
"eslint/no-fallthrough": "error",
|
||||
"eslint/no-func-assign": "error",
|
||||
"eslint/no-invalid-regexp": "error",
|
||||
"eslint/no-iterator": "error",
|
||||
"eslint/no-label-var": "error",
|
||||
"eslint/no-var": "error",
|
||||
"eslint/no-labels": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"allowLoop": true,
|
||||
"allowSwitch": false
|
||||
}
|
||||
],
|
||||
"eslint/no-new-func": "warn",
|
||||
"eslint/no-script-url": "warn",
|
||||
"eslint/no-self-assign": "warn",
|
||||
"eslint/no-self-compare": "warn",
|
||||
"eslint/no-sequences": "warn",
|
||||
"eslint/no-shadow-restricted-names": "warn",
|
||||
"eslint/no-sparse-arrays": "warn",
|
||||
"eslint/no-template-curly-in-string": "warn",
|
||||
"eslint/no-this-before-super": "warn",
|
||||
"eslint/no-throw-literal": "warn",
|
||||
"eslint/no-unreachable": "warn",
|
||||
"eslint/no-obj-calls": "warn",
|
||||
"eslint/no-new-wrappers": "warn",
|
||||
"eslint/no-unsafe-negation": "warn",
|
||||
"eslint/no-multi-str": "warn",
|
||||
"eslint/no-global-assign": "warn",
|
||||
"eslint/no-lone-blocks": "warn",
|
||||
"eslint/no-unused-labels": "warn",
|
||||
"eslint/no-object-constructor": "warn",
|
||||
"eslint/no-new-native-nonconstructor": "warn",
|
||||
"eslint/no-redeclare": "warn",
|
||||
"eslint/no-useless-computed-key": "warn",
|
||||
"eslint/no-useless-concat": "warn",
|
||||
"eslint/no-useless-escape": "warn",
|
||||
"eslint/require-yield": "warn",
|
||||
"eslint/getter-return": "warn",
|
||||
"eslint/unicode-bom": ["warn", "never"],
|
||||
"eslint/no-use-isnan": "warn",
|
||||
"eslint/valid-typeof": "warn",
|
||||
"eslint/no-new-func": "error",
|
||||
"eslint/no-script-url": "error",
|
||||
"eslint/no-self-assign": "error",
|
||||
"eslint/no-self-compare": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
"eslint/no-shadow-restricted-names": "error",
|
||||
"eslint/no-sparse-arrays": "error",
|
||||
"eslint/no-template-curly-in-string": "error",
|
||||
"eslint/no-this-before-super": "error",
|
||||
"eslint/no-throw-literal": "error",
|
||||
"eslint/no-unreachable": "error",
|
||||
"eslint/no-obj-calls": "error",
|
||||
"eslint/no-new-wrappers": "error",
|
||||
"eslint/no-unsafe-negation": "error",
|
||||
"eslint/no-multi-str": "error",
|
||||
"eslint/no-global-assign": "error",
|
||||
"eslint/no-lone-blocks": "error",
|
||||
"eslint/no-unused-labels": "error",
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-new-native-nonconstructor": "error",
|
||||
"eslint/no-redeclare": "error",
|
||||
"eslint/no-useless-computed-key": "error",
|
||||
"eslint/no-useless-concat": "error",
|
||||
"eslint/no-useless-escape": "error",
|
||||
"eslint/require-yield": "error",
|
||||
"eslint/getter-return": "error",
|
||||
"eslint/unicode-bom": ["error", "never"],
|
||||
"eslint/no-use-isnan": "error",
|
||||
"eslint/valid-typeof": "error",
|
||||
"eslint/no-useless-rename": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"ignoreDestructuring": false,
|
||||
"ignoreImport": false,
|
||||
"ignoreExport": false
|
||||
}
|
||||
],
|
||||
"eslint/no-with": "warn",
|
||||
"eslint/no-regex-spaces": "warn",
|
||||
"eslint/no-with": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-restricted-globals": [
|
||||
"warn",
|
||||
"error",
|
||||
|
||||
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
|
||||
"addEventListener",
|
||||
@@ -294,7 +299,7 @@
|
||||
"top"
|
||||
],
|
||||
"eslint/no-restricted-imports": [
|
||||
"warn",
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
@@ -333,6 +338,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`"
|
||||
@@ -340,9 +349,9 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"eslint/no-useless-constructor": "warn",
|
||||
"eslint/no-undef": "warn",
|
||||
"eslint/no-unused-expressions": "warn"
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-undef": "error",
|
||||
"eslint/no-unused-expressions": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
@@ -385,6 +394,12 @@
|
||||
"typescript-paths/absolute-import": ["error", { "enableAlias": false }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/desktop-client/src/style/themes/*"],
|
||||
"rules": {
|
||||
"eslint/no-restricted-imports": "off"
|
||||
}
|
||||
},
|
||||
// TODO: enable these
|
||||
{
|
||||
"files": [
|
||||
|
||||
59
AGENTS.md
59
AGENTS.md
@@ -42,6 +42,12 @@ yarn start:desktop
|
||||
- Use `yarn workspace <workspace-name> run <command>` for workspace-specific tasks
|
||||
- Tests run once and exit by default (using `vitest --run`)
|
||||
|
||||
### ⚠️ CRITICAL REQUIREMENT: AI-Generated Commit Messages and PR Titles
|
||||
|
||||
**ALL commit messages and PR titles MUST be prefixed with `[AI]`.** No exceptions.
|
||||
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for the full specification, including git safety rules, pre-commit checklist, and PR workflow.
|
||||
|
||||
### Task Orchestration with Lage
|
||||
|
||||
The project uses **[lage](https://microsoft.github.io/lage/)** (a task runner for JavaScript monorepos) to efficiently run tests and other tasks across multiple workspaces:
|
||||
@@ -292,6 +298,7 @@ Always run `yarn typecheck` before committing.
|
||||
|
||||
**React Patterns:**
|
||||
|
||||
- The project uses **React Compiler** (`babel-plugin-react-compiler`) in the desktop-client. The compiler auto-memoizes component bodies, so you can omit manual `useCallback`, `useMemo`, and `React.memo` when adding or refactoring code; prefer inline callbacks and values unless a stable identity is required by a non-compiled dependency.
|
||||
- Don't use `React.FunctionComponent` or `React.FC` - type props directly
|
||||
- Don't use `React.*` patterns - use named imports instead
|
||||
- Use `<Link>` instead of `<a>` tags
|
||||
@@ -338,11 +345,7 @@ Always maintain newlines between import groups.
|
||||
|
||||
**Git Commands:**
|
||||
|
||||
- Never update git config
|
||||
- Never run destructive git operations (force push, hard reset) unless explicitly requested
|
||||
- Never skip hooks (--no-verify, --no-gpg-sign)
|
||||
- Never force push to main/master
|
||||
- Never commit unless explicitly asked
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete git safety rules, commit message requirements, and PR workflow.
|
||||
|
||||
## File Structure Patterns
|
||||
|
||||
@@ -505,7 +508,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
1. Clean build artifacts: `rm -rf packages/*/dist packages/*/lib-dist packages/*/build`
|
||||
2. Reinstall dependencies: `yarn install`
|
||||
3. Check Node.js version (requires >=20)
|
||||
3. Check Node.js version (requires >=22)
|
||||
4. Check Yarn version (requires ^4.9.1)
|
||||
|
||||
## Testing Patterns
|
||||
@@ -541,6 +544,7 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
|
||||
|
||||
Before committing changes, ensure:
|
||||
|
||||
- [ ] Commit and PR rules followed (see [PR and Commit Rules](.github/agents/pr-and-commit-rules.md))
|
||||
- [ ] `yarn typecheck` passes
|
||||
- [ ] `yarn lint:fix` has been run
|
||||
- [ ] Relevant tests pass
|
||||
@@ -553,9 +557,7 @@ Before committing changes, ensure:
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
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.
|
||||
See [PR and Commit Rules](.github/agents/pr-and-commit-rules.md) for complete PR creation rules, including title prefix requirements, labeling, and PR template handling.
|
||||
|
||||
## Code Review Guidelines
|
||||
|
||||
@@ -586,7 +588,7 @@ yarn install:server
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- **Node.js**: >=20
|
||||
- **Node.js**: >=22
|
||||
- **Yarn**: ^4.9.1 (managed by packageManager field)
|
||||
- **Browser Targets**: Electron >= 35.0, modern browsers (see browserslist)
|
||||
|
||||
@@ -599,3 +601,40 @@ The codebase is actively being migrated:
|
||||
- **React.\* → Named Imports**: Legacy React.\* patterns being removed
|
||||
|
||||
When working with older code, follow the newer patterns described in this guide.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
### Services overview
|
||||
|
||||
| Service | Command | Port | Required |
|
||||
| ------------------- | ----------------------- | ---- | ----------------------------- |
|
||||
| Web Frontend (Vite) | `yarn start` | 3001 | Yes |
|
||||
| Sync Server | `yarn start:server-dev` | 5006 | Optional (sync features only) |
|
||||
|
||||
All storage is **SQLite** (file-based via `better-sqlite3`). No external databases or services are needed.
|
||||
|
||||
### Running the app
|
||||
|
||||
- `yarn start` builds the plugins-service worker, loot-core browser backend, and starts the Vite dev server on port **3001**.
|
||||
- `yarn start:server-dev` starts both the sync server (port 5006) and the web frontend together.
|
||||
- The Vite HMR dev server serves many unbundled modules. In constrained environments, the browser may hit `ERR_INSUFFICIENT_RESOURCES`. If that happens, use `yarn build:browser` followed by serving the built output from `packages/desktop-client/build/` with proper COOP/COEP headers (`Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`).
|
||||
|
||||
### Lint, test, typecheck
|
||||
|
||||
Standard commands documented in `package.json` scripts and the Quick Start section above:
|
||||
|
||||
- `yarn lint` / `yarn lint:fix` (uses oxlint + oxfmt)
|
||||
- `yarn test` (lage across all workspaces)
|
||||
- `yarn typecheck` (tsc + lage typecheck)
|
||||
|
||||
### Testing and previewing the app
|
||||
|
||||
When running the app for manual testing or demos, use **"View demo"** on the initial setup screen (after selecting "Don't use a server"). This creates a test budget pre-populated with realistic sample data (accounts, transactions, categories, and budgeted amounts), which is far more useful than starting with an empty budget.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The `engines` field requires **Node.js >=22** and **Yarn ^4.9.1**. The `.nvmrc` specifies `v22/*`.
|
||||
- Pre-commit hook runs `lint-staged` (oxfmt + oxlint) via Husky. Run `yarn prepare` once after install to set up hooks.
|
||||
- Lage caches test results in `.lage/`. If tests behave unexpectedly, clear with `rm -rf .lage`.
|
||||
- Native modules (`better-sqlite3`, `bcrypt`) require build tools (`gcc`, `make`, `python3`). These are pre-installed in the Cloud VM.
|
||||
- All yarn commands must be run from the repository root, never from child workspaces.
|
||||
|
||||
@@ -59,6 +59,9 @@ yarn workspace loot-core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
yarn workspace @actual-app/sync-server build
|
||||
|
||||
# Emit loot-core declarations so desktop-electron (which includes typings/window.ts) can build
|
||||
yarn workspace loot-core exec tsc -p tsconfig.json
|
||||
|
||||
yarn workspace desktop-electron update-client
|
||||
|
||||
(
|
||||
|
||||
@@ -178,4 +178,4 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
run();
|
||||
void run();
|
||||
|
||||
@@ -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.57.0-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** @type {import('lage').ConfigOptions} */
|
||||
module.exports = {
|
||||
pipeline: {
|
||||
typecheck: {
|
||||
type: 'npmScript',
|
||||
},
|
||||
test: {
|
||||
type: 'npmScript',
|
||||
options: {
|
||||
|
||||
26
package.json
26
package.json
@@ -54,37 +54,37 @@
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/loot-core",
|
||||
"rebuild-node": "yarn workspace loot-core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --deny-warnings",
|
||||
"lint:fix": "oxfmt . && oxlint --deny-warnings --fix",
|
||||
"lint": "yarn workspace @actual-app/api clean && oxfmt --check . && oxlint --type-aware",
|
||||
"lint:fix": "yarn workspace @actual-app/api clean && oxfmt . && oxlint --fix --type-aware",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"typecheck": "yarn tsc --incremental && tsc-strict",
|
||||
"typecheck": "yarn workspace @actual-app/api clean && tsc -b && tsc -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"baseline-browser-mapping": "^2.9.14",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lage": "^2.14.15",
|
||||
"lage": "^2.14.17",
|
||||
"lint-staged": "^16.2.7",
|
||||
"minimatch": "^10.1.1",
|
||||
"minimatch": "^10.1.2",
|
||||
"node-jq": "^6.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.26.0",
|
||||
"oxlint": "^1.41.0",
|
||||
"p-limit": "^7.2.0",
|
||||
"oxfmt": "^0.32.0",
|
||||
"oxlint": "^1.47.0",
|
||||
"oxlint-tsgolint": "^0.13.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"prompts": "^2.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-strict-plugin": "^2.4.4"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"rollup": "4.40.1",
|
||||
@@ -95,7 +95,7 @@
|
||||
"oxfmt --no-error-on-unmatched-pattern"
|
||||
],
|
||||
"*.{js,mjs,jsx,ts,tsx}": [
|
||||
"oxlint --deny-warnings --fix"
|
||||
"oxlint --fix --type-aware"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import { type RuleEntity } from 'loot-core/types/models';
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
import * as api from './index';
|
||||
|
||||
@@ -356,6 +356,143 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// apis: createTag, getTags, updateTag, deleteTag
|
||||
test('Tags: successfully complete tag operations', async () => {
|
||||
// Create tags
|
||||
const tagId1 = await api.createTag({ tag: 'test-tag1', color: '#ff0000' });
|
||||
const tagId2 = await api.createTag({
|
||||
tag: 'test-tag2',
|
||||
description: 'A test tag',
|
||||
});
|
||||
|
||||
let tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId1,
|
||||
tag: 'test-tag1',
|
||||
color: '#ff0000',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: tagId2,
|
||||
tag: 'test-tag2',
|
||||
description: 'A test tag',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Update tag
|
||||
await api.updateTag(tagId1, { tag: 'updated-tag', color: '#00ff00' });
|
||||
tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId1,
|
||||
tag: 'updated-tag',
|
||||
color: '#00ff00',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Delete tag
|
||||
await api.deleteTag(tagId2);
|
||||
tags = await api.getTags();
|
||||
expect(tags).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ id: tagId2 })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: create tag with minimal fields', async () => {
|
||||
const tagId = await api.createTag({ tag: 'minimal-tag' });
|
||||
const tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'minimal-tag',
|
||||
color: null,
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: update single field only', async () => {
|
||||
const tagId = await api.createTag({ tag: 'original', color: '#ff0000' });
|
||||
|
||||
// Update only color, tag and description should remain unchanged
|
||||
await api.updateTag(tagId, { color: '#00ff00' });
|
||||
|
||||
const tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'original',
|
||||
color: '#00ff00',
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: handle null values correctly', async () => {
|
||||
const tagId = await api.createTag({
|
||||
tag: 'with-nulls',
|
||||
color: null,
|
||||
description: null,
|
||||
});
|
||||
|
||||
const tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
color: null,
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('Tags: clear optional field', async () => {
|
||||
const tagId = await api.createTag({
|
||||
tag: 'clearable',
|
||||
color: '#ff0000',
|
||||
description: 'will be cleared',
|
||||
});
|
||||
|
||||
// Clear color by setting to null
|
||||
await api.updateTag(tagId, { color: null });
|
||||
|
||||
let tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'clearable',
|
||||
color: null,
|
||||
description: 'will be cleared',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Clear description by setting to null
|
||||
await api.updateTag(tagId, { description: null });
|
||||
|
||||
tags = await api.getTags();
|
||||
expect(tags).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: tagId,
|
||||
tag: 'clearable',
|
||||
color: null,
|
||||
description: null,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
|
||||
test('Rules: successfully update rules', async () => {
|
||||
await api.createPayee({ name: 'test-payee' });
|
||||
|
||||
@@ -5,8 +5,10 @@ import type {
|
||||
APIFileEntity,
|
||||
APIPayeeEntity,
|
||||
APIScheduleEntity,
|
||||
APITagEntity,
|
||||
} from 'loot-core/server/api-models';
|
||||
import type { Query } from 'loot-core/shared/query';
|
||||
import type { ImportTransactionsOpts } from 'loot-core/types/api-handlers';
|
||||
import type { Handlers } from 'loot-core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
@@ -125,11 +127,6 @@ export function addTransactions(
|
||||
});
|
||||
}
|
||||
|
||||
export type ImportTransactionsOpts = {
|
||||
defaultCleared?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
export function importTransactions(
|
||||
accountId: APIAccountEntity['id'],
|
||||
transactions: ImportTransactionEntity[],
|
||||
@@ -274,6 +271,25 @@ export function deletePayee(id: APIPayeeEntity['id']) {
|
||||
return send('api/payee-delete', { id });
|
||||
}
|
||||
|
||||
export function getTags() {
|
||||
return send('api/tags-get');
|
||||
}
|
||||
|
||||
export function createTag(tag: Omit<APITagEntity, 'id'>) {
|
||||
return send('api/tag-create', { tag });
|
||||
}
|
||||
|
||||
export function updateTag(
|
||||
id: APITagEntity['id'],
|
||||
fields: Partial<Omit<APITagEntity, 'id'>>,
|
||||
) {
|
||||
return send('api/tag-update', { id, fields });
|
||||
}
|
||||
|
||||
export function deleteTag(id: APITagEntity['id']) {
|
||||
return send('api/tag-delete', { id });
|
||||
}
|
||||
|
||||
export function mergePayees(
|
||||
targetId: APIPayeeEntity['id'],
|
||||
mergeIds: APIPayeeEntity['id'][],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "26.2.0",
|
||||
"version": "26.3.0",
|
||||
"description": "An API for Actual",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
@@ -12,16 +12,17 @@
|
||||
"scripts": {
|
||||
"build:app": "yarn workspace loot-core build:api",
|
||||
"build:crdt": "yarn workspace @actual-app/crdt build",
|
||||
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
|
||||
"build:node": "tsc && tsc-alias",
|
||||
"build:migrations": "cp migrations/*.sql dist/migrations",
|
||||
"build:default-db": "cp default-db.sqlite dist/",
|
||||
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
|
||||
"test": "yarn run clean && yarn run build:app && yarn run build:crdt && vitest --run",
|
||||
"clean": "rm -rf dist @types"
|
||||
"clean": "rm -rf dist @types",
|
||||
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/crdt": "workspace:^",
|
||||
"better-sqlite3": "^12.5.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"compare-versions": "^6.1.1",
|
||||
"node-fetch": "^3.3.2",
|
||||
"uuid": "^13.0.0"
|
||||
@@ -29,7 +30,8 @@
|
||||
"devDependencies": {
|
||||
"tsc-alias": "^1.8.16",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
@@ -8,12 +9,18 @@
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": ".",
|
||||
"declarationDir": "@types",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
}
|
||||
// TEMPORARY
|
||||
"loot-core/*": ["../loot-core/src/*"]
|
||||
},
|
||||
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
1
packages/api/typings/pegjs.d.ts
vendored
Normal file
1
packages/api/typings/pegjs.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.pegjs';
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// overview:
|
||||
// 1. Identify the migrations in packages/loot-core/migrations/* on `master` and HEAD
|
||||
// 2. Make sure that any new migrations on HEAD are dated after the latest migration on `master`.
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
import { spawnSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const migrationsDir = path.join(
|
||||
__dirname,
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'packages',
|
||||
@@ -16,7 +16,7 @@ const migrationsDir = path.join(
|
||||
'migrations',
|
||||
);
|
||||
|
||||
function readMigrations(ref) {
|
||||
function readMigrations(ref: string) {
|
||||
const { stdout } = spawnSync('git', [
|
||||
'ls-tree',
|
||||
'--name-only',
|
||||
@@ -3,9 +3,18 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "vitest --run"
|
||||
"tsx": "node --import=extensionless/register --experimental-strip-types",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.0.16"
|
||||
"extensionless": "^2.0.6",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
"ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
15
packages/ci-actions/tsconfig.json
Normal file
15
packages/ci-actions/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*", "bin/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -13,7 +13,8 @@ function getAbsolutePath(value: string) {
|
||||
}
|
||||
const config: StorybookConfig = {
|
||||
stories: [
|
||||
'../src/Introduction.mdx',
|
||||
'../src/Concepts/*.mdx',
|
||||
'../src/Themes/*.mdx',
|
||||
'../src/**/*.mdx',
|
||||
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
|
||||
],
|
||||
@@ -26,6 +27,7 @@ const config: StorybookConfig = {
|
||||
core: {
|
||||
disableTelemetry: true,
|
||||
},
|
||||
staticDirs: ['./public'],
|
||||
async viteFinal(config) {
|
||||
const { mergeConfig } = await import('vite');
|
||||
|
||||
|
||||
99
packages/component-library/.storybook/manager-head.html
Normal file
99
packages/component-library/.storybook/manager-head.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
Override the default favicon used in the Storybook in the browser tab.
|
||||
-->
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
type="image/x-icon"
|
||||
href="https://design.actualbudget.org/favicon.ico"
|
||||
/>
|
||||
<link href="/global-styles.css" rel="stylesheet" />
|
||||
|
||||
<!-- Primary meta tags -->
|
||||
<meta name="title" content="Actual Budget Design System" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Actual Budget is a super fast and privacy-focused app for managing your finances. At its heart is the well proven and much loved Envelope Budgeting methodology."
|
||||
/>
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://design.actualbudget.org" />
|
||||
<meta property="og:title" content="Actual Budget Design System" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Actual Budget is a super fast and privacy-focused app for managing your finances. At its heart is the well proven and much loved Envelope Budgeting methodology."
|
||||
/>
|
||||
<meta property="og:locale" content="en" />
|
||||
<meta property="og:image" content="https://design.actualbudget.org/og.webp" />
|
||||
<meta property="og:image:type" content="image/webp" />
|
||||
<meta property="og:image:alt" content="Actual Budget Design System" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:image" content="https://design.actualbudget.org/og.webp" />
|
||||
|
||||
<!--
|
||||
Override the default styles used in the Storybook svg icons for the left tree panel.
|
||||
|
||||
@see https://storybook.js.org/docs/react/configure/theming#css-escape-hatches
|
||||
|
||||
> 💡 NOTE:
|
||||
>
|
||||
> This is brittle way for providing custom non thenable styles for manager UI
|
||||
>
|
||||
> Those selectors might change on any storybook version bump.
|
||||
-->
|
||||
|
||||
<style>
|
||||
#storybook-explorer-searchfield {
|
||||
font-weight: 400 !important;
|
||||
font-size: 14px !important;
|
||||
line-height: 14px !important;
|
||||
}
|
||||
|
||||
.sidebar-item svg,
|
||||
.sidebar-svg-icon {
|
||||
color: #272630 !important;
|
||||
}
|
||||
|
||||
.sidebar-item[data-selected='true'] svg,
|
||||
.sidebar-item[data-selected='true'] .sidebar-svg-icon {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.sidebar-subheading button,
|
||||
button[data-action='collapse-ref'] {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 24px !important;
|
||||
letter-spacing: -0.01em !important;
|
||||
text-transform: none !important;
|
||||
color: #272630 !important;
|
||||
}
|
||||
|
||||
.sidebar-subheading:hover button,
|
||||
button[data-action='collapse-ref']:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
align-items: center !important;
|
||||
font-weight: 400 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 24px !important;
|
||||
color: #272630 !important;
|
||||
}
|
||||
|
||||
.sidebar-item a {
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.sidebar-item[data-selected='true'] {
|
||||
font-weight: 600 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 24px !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { type ReactNode } from 'react';
|
||||
import type { Preview } from '@storybook/react-vite';
|
||||
|
||||
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
|
||||
// TODO: this needs refactoring
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
|
||||
9
packages/component-library/.storybook/public/_headers
Normal file
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
|
||||
|
||||
BIN
packages/component-library/.storybook/public/favicon.ico
Normal file
BIN
packages/component-library/.storybook/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
/* Custom Storybook Styling */
|
||||
BIN
packages/component-library/.storybook/public/og.webp
Normal file
BIN
packages/component-library/.storybook/public/og.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -39,28 +39,30 @@
|
||||
"test": "npm-run-all -cp 'test:*'",
|
||||
"test:web": "ENV=web vitest --run -c vitest.web.config.ts",
|
||||
"start:storybook": "storybook dev -p 6006",
|
||||
"build:storybook": "storybook build"
|
||||
"build:storybook": "storybook build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"react-aria-components": "^1.14.0",
|
||||
"react-aria-components": "^1.15.1",
|
||||
"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",
|
||||
"@storybook/addon-a11y": "^10.2.7",
|
||||
"@storybook/addon-docs": "^10.2.7",
|
||||
"@storybook/react-vite": "^10.2.7",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"eslint-plugin-storybook": "^10.2.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"storybook": "^10.2.0",
|
||||
"vitest": "^4.0.16"
|
||||
"eslint-plugin-storybook": "^10.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.7",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.2",
|
||||
"react-dom": ">=18.2"
|
||||
"react": ">=19.2",
|
||||
"react-dom": ">=19.2"
|
||||
}
|
||||
}
|
||||
|
||||
139
packages/component-library/src/AlignedText.stories.tsx
Normal file
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: 'Components/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 CSSProperties, type ReactNode } from 'react';
|
||||
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { Block } from './Block';
|
||||
import { View } from './View';
|
||||
|
||||
111
packages/component-library/src/Block.stories.tsx
Normal file
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: 'Components/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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type HTMLProps, type Ref } from 'react';
|
||||
import type { HTMLProps, Ref } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type BlockProps = HTMLProps<HTMLDivElement> & {
|
||||
innerRef?: Ref<HTMLDivElement>;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fn } from 'storybook/test';
|
||||
import { Button } from './Button';
|
||||
|
||||
const meta = {
|
||||
title: 'Button',
|
||||
title: 'Components/Button',
|
||||
component: Button,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
@@ -20,7 +20,6 @@ export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useMemo,
|
||||
type ComponentPropsWithoutRef,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import React, { forwardRef, useMemo } from 'react';
|
||||
import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
|
||||
import { Button as ReactAriaButton } from 'react-aria-components';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
82
packages/component-library/src/Card.stories.tsx
Normal file
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: 'Components/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,5 @@
|
||||
import { forwardRef, type ComponentProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { theme } from './theme';
|
||||
import { View } from './View';
|
||||
|
||||
108
packages/component-library/src/ColorPicker.stories.tsx
Normal file
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: 'Components/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,4 +1,4 @@
|
||||
import { type ChangeEvent, type ReactNode } from 'react';
|
||||
import type { ChangeEvent, ReactNode } from 'react';
|
||||
import {
|
||||
ColorPicker as AriaColorPicker,
|
||||
ColorSwatch as AriaColorSwatch,
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
parseColor,
|
||||
type ColorPickerProps as AriaColorPickerProps,
|
||||
type ColorSwatchProps,
|
||||
} from 'react-aria-components';
|
||||
import type {
|
||||
ColorPickerProps as AriaColorPickerProps,
|
||||
ColorSwatchProps,
|
||||
} from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Introduction" />
|
||||
<Meta title="Concepts/Introduction" />
|
||||
|
||||
# Actual Budget Component Library
|
||||
|
||||
90
packages/component-library/src/FormError.stories.tsx
Normal file
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: 'Components/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 CSSProperties, type ReactNode } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { View } from './View';
|
||||
|
||||
|
||||
86
packages/component-library/src/InitialFocus.stories.tsx
Normal file
86
packages/component-library/src/InitialFocus.stories.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { type Ref } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { InitialFocus } from './InitialFocus';
|
||||
import { Input } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/InitialFocus',
|
||||
component: InitialFocus,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof InitialFocus>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const WithInput: Story = {
|
||||
args: {
|
||||
children: <Input placeholder="This input will be focused on mount" />,
|
||||
},
|
||||
render: args => (
|
||||
<View style={{ width: 300 }}>
|
||||
<InitialFocus {...args} />
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InitialFocus automatically focuses its child element when the component mounts. The input will receive focus and have its text selected.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFunctionChild: Story = {
|
||||
args: {
|
||||
children: <Input placeholder="Focused via function child" />,
|
||||
},
|
||||
render: () => (
|
||||
<View style={{ width: 300 }}>
|
||||
<InitialFocus>
|
||||
{ref => (
|
||||
<Input
|
||||
ref={ref as Ref<HTMLInputElement>}
|
||||
placeholder="Focused via function child"
|
||||
/>
|
||||
)}
|
||||
</InitialFocus>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InitialFocus can accept a function as child for components that need custom ref handling.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleInputsOnlyFirstFocused: Story = {
|
||||
args: {
|
||||
children: <Input placeholder="This one is focused" />,
|
||||
},
|
||||
render: args => (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<InitialFocus {...args} />
|
||||
<Input placeholder="This one is not focused" />
|
||||
<Input placeholder="This one is also not focused" />
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'When multiple inputs are present, only the one wrapped in InitialFocus will receive initial focus.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import type { ReactElement, Ref, RefObject } from 'react';
|
||||
|
||||
type InitialFocusProps<T extends HTMLElement> = {
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from 'react';
|
||||
import { forwardRef, type Ref } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { Ref } from 'react';
|
||||
|
||||
import { render } from '@testing-library/react';
|
||||
|
||||
|
||||
101
packages/component-library/src/InlineField.stories.tsx
Normal file
101
packages/component-library/src/InlineField.stories.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { InlineField } from './InlineField';
|
||||
import { Input } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/InlineField',
|
||||
component: InlineField,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof InlineField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: 'Name',
|
||||
width: 300,
|
||||
children: <Input style={{ flex: 1 }} />,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'InlineField displays a label and input side-by-side in a horizontal layout.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomLabelWidth: Story = {
|
||||
args: {
|
||||
label: 'Email Address',
|
||||
labelWidth: 120,
|
||||
width: 400,
|
||||
children: <Input style={{ flex: 1 }} placeholder="user@example.com" />,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Custom label width can be specified to accommodate longer labels.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleFields: Story = {
|
||||
args: {
|
||||
label: 'First Name',
|
||||
width: 300,
|
||||
},
|
||||
render: args => (
|
||||
<View style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<InlineField {...args}>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</InlineField>
|
||||
<InlineField label="Last Name" width={300}>
|
||||
<Input style={{ flex: 1 }} />
|
||||
</InlineField>
|
||||
<InlineField label="Email" width={300}>
|
||||
<Input style={{ flex: 1 }} type="email" />
|
||||
</InlineField>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Multiple InlineFields stack vertically with consistent label alignment.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithPercentageWidth: Story = {
|
||||
args: {
|
||||
label: 'Description',
|
||||
width: '100%',
|
||||
children: <Input style={{ flex: 1 }} />,
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 400 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Width can be specified as a percentage for responsive layouts.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type InlineFieldProps = {
|
||||
label: ReactNode;
|
||||
|
||||
215
packages/component-library/src/Input.stories.tsx
Normal file
215
packages/component-library/src/Input.stories.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Input',
|
||||
component: Input,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Input>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
placeholder: 'Enter text...',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 250 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic input field with placeholder text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithValue: Story = {
|
||||
args: {
|
||||
defaultValue: 'Hello World',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 250 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Input with a pre-filled value.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
defaultValue: 'Disabled input',
|
||||
disabled: true,
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 250 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Disabled inputs prevent user interaction and display muted text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOnEnter: Story = {
|
||||
render: function Render() {
|
||||
const [submittedValue, setSubmittedValue] = useState('');
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 250,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Press Enter to submit"
|
||||
onEnter={value => setSubmittedValue(value)}
|
||||
/>
|
||||
{submittedValue && <span>Submitted: {submittedValue}</span>}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The onEnter callback is triggered when the user presses Enter.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOnEscape: Story = {
|
||||
render: function Render() {
|
||||
const [escaped, setEscaped] = useState(false);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 250,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Press Escape to cancel"
|
||||
onEscape={() => setEscaped(true)}
|
||||
/>
|
||||
{escaped && <span>Escape pressed!</span>}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The onEscape callback is triggered when the user presses Escape.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithOnChangeValue: Story = {
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
width: 250,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
placeholder="Type something..."
|
||||
onChangeValue={newValue => setValue(newValue)}
|
||||
/>
|
||||
<span>Current value: {value}</span>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The onChangeValue callback provides the new value on each keystroke.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NumberInput: Story = {
|
||||
args: {
|
||||
type: 'number',
|
||||
placeholder: '0',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 150 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Input configured for numeric values.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const PasswordInput: Story = {
|
||||
args: {
|
||||
type: 'password',
|
||||
placeholder: 'Enter password',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 250 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Password input masks the entered text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, {
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithRef,
|
||||
type FocusEvent,
|
||||
type KeyboardEvent,
|
||||
import React from 'react';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ComponentPropsWithRef,
|
||||
FocusEvent,
|
||||
KeyboardEvent,
|
||||
} from 'react';
|
||||
import { Input as ReactAriaInput } from 'react-aria-components';
|
||||
|
||||
|
||||
97
packages/component-library/src/Label.stories.tsx
Normal file
97
packages/component-library/src/Label.stories.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { Label } from './Label';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Label',
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Label>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
title: 'Username',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic label component for form fields.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithInput: Story = {
|
||||
args: {
|
||||
title: 'Email Address',
|
||||
},
|
||||
render: args => (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<Label {...args} />
|
||||
<Input placeholder="user@example.com" style={{ width: 250 }} />
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Label used with an input field in a vertical layout.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleLabels: Story = {
|
||||
args: {
|
||||
title: 'First Name',
|
||||
},
|
||||
render: args => (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 15 }}>
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<Label {...args} />
|
||||
<Input style={{ width: 250 }} />
|
||||
</View>
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<Label title="Last Name" />
|
||||
<Input style={{ width: 250 }} />
|
||||
</View>
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<Label title="Password" />
|
||||
<Input type="password" style={{ width: 250 }} />
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Multiple labels and inputs in a form layout.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyle: Story = {
|
||||
args: {
|
||||
title: 'Custom Styled Label',
|
||||
style: {
|
||||
fontSize: 16,
|
||||
color: '#007bff',
|
||||
textAlign: 'left',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Label with custom styling applied.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { forwardRef, type CSSProperties, type ReactNode } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
|
||||
import { styles } from './styles';
|
||||
import { Text } from './Text';
|
||||
|
||||
243
packages/component-library/src/Menu.stories.tsx
Normal file
243
packages/component-library/src/Menu.stories.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { SvgAdd, SvgTrash } from './icons/v1';
|
||||
import { SvgPencil1 } from './icons/v2';
|
||||
import { Menu, type MenuItem } from './Menu';
|
||||
import { Text } from './Text';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Menu',
|
||||
component: Menu,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Menu>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const basicItems: Array<MenuItem<string>> = [
|
||||
{ name: 'edit', text: 'Edit' },
|
||||
{ name: 'duplicate', text: 'Duplicate' },
|
||||
{ name: 'delete', text: 'Delete' },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic menu with simple text items.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ name: 'add', text: 'Add New', icon: SvgAdd },
|
||||
{ name: 'edit', text: 'Edit', icon: SvgPencil1 },
|
||||
{ name: 'delete', text: 'Delete', icon: SvgTrash },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menu items can include icons for visual clarity.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSeparator: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ name: 'cut', text: 'Cut' },
|
||||
{ name: 'copy', text: 'Copy' },
|
||||
{ name: 'paste', text: 'Paste' },
|
||||
Menu.line,
|
||||
{ name: 'delete', text: 'Delete' },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menu.line creates a visual separator between menu sections.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ type: Menu.label, name: 'Actions', text: 'Actions' },
|
||||
{ name: 'edit', text: 'Edit' },
|
||||
{ name: 'duplicate', text: 'Duplicate' },
|
||||
Menu.line,
|
||||
{ type: Menu.label, name: 'Danger Zone', text: 'Danger Zone' },
|
||||
{ name: 'delete', text: 'Delete' },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menu.label items create section headers within the menu.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDisabledItems: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ name: 'edit', text: 'Edit' },
|
||||
{ name: 'duplicate', text: 'Duplicate', disabled: true },
|
||||
{ name: 'delete', text: 'Delete' },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Disabled menu items are visually muted and non-interactive.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithKeyboardShortcuts: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ name: 'cut', text: 'Cut', key: 'ctrl + X' },
|
||||
{ name: 'copy', text: 'Copy', key: 'ctrl + C' },
|
||||
{ name: 'paste', text: 'Paste', key: 'ctrl + V' },
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menu items can display keyboard shortcuts.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithToggle: Story = {
|
||||
args: {
|
||||
items: [],
|
||||
},
|
||||
render: function Render() {
|
||||
const [settings, setSettings] = useState({
|
||||
notifications: true,
|
||||
darkMode: false,
|
||||
autoSave: true,
|
||||
});
|
||||
|
||||
const items: Array<MenuItem<'notifications' | 'darkMode' | 'autoSave'>> = [
|
||||
{
|
||||
name: 'notifications',
|
||||
text: 'Notifications',
|
||||
toggle: settings.notifications,
|
||||
},
|
||||
{ name: 'darkMode', text: 'Dark Mode', toggle: settings.darkMode },
|
||||
{ name: 'autoSave', text: 'Auto Save', toggle: settings.autoSave },
|
||||
];
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={items}
|
||||
onMenuSelect={name => {
|
||||
setSettings(prev => ({ ...prev, [name]: !prev[name] }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menu items can include toggles for boolean settings.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeaderAndFooter: Story = {
|
||||
args: {
|
||||
header: (
|
||||
<View style={{ padding: 10, borderBottom: '1px solid #ccc' }}>
|
||||
<Text style={{ fontWeight: 'bold' }}>Menu Title</Text>
|
||||
</View>
|
||||
),
|
||||
footer: (
|
||||
<View style={{ padding: 10, borderTop: '1px solid #ccc' }}>
|
||||
<Text style={{ fontSize: 11, color: '#666' }}>3 items</Text>
|
||||
</View>
|
||||
),
|
||||
items: basicItems,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menus can have custom header and footer content.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTooltips: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ name: 'edit', text: 'Edit', tooltip: 'Modify this item' },
|
||||
{
|
||||
name: 'duplicate',
|
||||
text: 'Duplicate',
|
||||
tooltip: 'Create a copy of this item',
|
||||
},
|
||||
{
|
||||
name: 'delete',
|
||||
text: 'Delete',
|
||||
tooltip: 'Permanently remove this item',
|
||||
},
|
||||
],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Menu items can have tooltips for additional context.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractiveExample: Story = {
|
||||
args: {
|
||||
items: basicItems,
|
||||
},
|
||||
render: function Render(args) {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Menu {...args} onMenuSelect={name => setSelected(String(name))} />
|
||||
{selected && (
|
||||
<Text style={{ textAlign: 'center' }}>Selected: {selected}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Interactive menu that shows the selected item.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,11 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ComponentType,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
ComponentProps,
|
||||
ComponentType,
|
||||
CSSProperties,
|
||||
KeyboardEvent,
|
||||
ReactNode,
|
||||
SVGProps,
|
||||
} from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
|
||||
134
packages/component-library/src/Paragraph.stories.tsx
Normal file
134
packages/component-library/src/Paragraph.stories.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Paragraph } from './Paragraph';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Paragraph',
|
||||
component: Paragraph,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Paragraph>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children:
|
||||
'This is a paragraph of text. Paragraphs are used to display blocks of text content with proper line height and spacing.',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 400 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic paragraph with default styling and bottom margin.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleParagraphs: Story = {
|
||||
render: () => (
|
||||
<View style={{ width: 400 }}>
|
||||
<Paragraph>
|
||||
This is the first paragraph. It has a bottom margin to create spacing
|
||||
between itself and the next paragraph.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
This is the second paragraph. Notice the consistent spacing between
|
||||
paragraphs which improves readability.
|
||||
</Paragraph>
|
||||
<Paragraph isLast>
|
||||
This is the last paragraph. It uses the isLast prop to remove the bottom
|
||||
margin since there is no following content.
|
||||
</Paragraph>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Multiple paragraphs stack with consistent spacing. Use isLast on the final paragraph.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const IsLast: Story = {
|
||||
args: {
|
||||
children: 'This paragraph has no bottom margin because isLast is true.',
|
||||
isLast: true,
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 400, border: '1px dashed #ccc', padding: 10 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'When isLast is true, the bottom margin is removed. Useful for the last paragraph in a section.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStyle: Story = {
|
||||
args: {
|
||||
children: 'This paragraph has custom styling applied.',
|
||||
style: {
|
||||
color: '#007bff',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 18,
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 400 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Custom styles can be applied to paragraphs.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
children:
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<View style={{ width: 400 }}>
|
||||
<Story />
|
||||
</View>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Longer paragraphs wrap properly and maintain consistent line height for readability.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type HTMLProps } from 'react';
|
||||
import type { HTMLProps } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type ParagraphProps = HTMLProps<HTMLDivElement> & {
|
||||
style?: CSSProperties;
|
||||
|
||||
153
packages/component-library/src/Popover.stories.tsx
Normal file
153
packages/component-library/src/Popover.stories.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { Menu } from './Menu';
|
||||
import { Popover } from './Popover';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Popover',
|
||||
component: Popover,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Popover>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const triggerRef = useRef(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button ref={triggerRef} onPress={() => setIsOpen(!isOpen)}>
|
||||
Toggle Popover
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<View style={{ padding: 10 }}>Popover content</View>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic popover triggered by a button click.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMenu: Story = {
|
||||
render: () => {
|
||||
const triggerRef = useRef(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button ref={triggerRef} onPress={() => setIsOpen(!isOpen)}>
|
||||
Open Menu
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={() => setIsOpen(false)}
|
||||
items={[
|
||||
{ name: 'edit', text: 'Edit' },
|
||||
{ name: 'duplicate', text: 'Duplicate' },
|
||||
Menu.line,
|
||||
{ name: 'delete', text: 'Delete' },
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Popover containing a menu, a common pattern for dropdown menus.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomPlacement: Story = {
|
||||
render: () => {
|
||||
const triggerRef = useRef(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button ref={triggerRef} onPress={() => setIsOpen(!isOpen)}>
|
||||
Bottom Start
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
placement="bottom start"
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<View style={{ padding: 10 }}>
|
||||
This popover is placed at bottom start.
|
||||
</View>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Popover with custom placement.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyle: Story = {
|
||||
render: () => {
|
||||
const triggerRef = useRef(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button ref={triggerRef} onPress={() => setIsOpen(!isOpen)}>
|
||||
Styled Popover
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
style={{ padding: 15, maxWidth: 250 }}
|
||||
>
|
||||
<View>
|
||||
This popover has custom padding and a constrained max width for
|
||||
longer content.
|
||||
</View>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Popover with custom styles applied.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, type ComponentProps } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { Popover as ReactAriaPopover } from 'react-aria-components';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
178
packages/component-library/src/Select.stories.tsx
Normal file
178
packages/component-library/src/Select.stories.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Menu } from './Menu';
|
||||
import { Select } from './Select';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Select',
|
||||
component: Select,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: ' ', // Remove autogenerated description (generated from JSDoc) to replace with custom description below
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: [
|
||||
['apple', 'Apple'],
|
||||
['banana', 'Banana'],
|
||||
['cherry', 'Cherry'],
|
||||
],
|
||||
value: 'apple',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic select dropdown with simple options.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDefaultLabel: Story = {
|
||||
args: {
|
||||
options: [
|
||||
['small', 'Small'],
|
||||
['medium', 'Medium'],
|
||||
['large', 'Large'],
|
||||
],
|
||||
value: '',
|
||||
defaultLabel: 'Select a size...',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'When the selected value is not in the options, the defaultLabel is displayed.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSeparator: Story = {
|
||||
args: {
|
||||
options: [
|
||||
['recent-1', 'Budget 2024'],
|
||||
['recent-2', 'Budget 2025'],
|
||||
Menu.line,
|
||||
['all', 'View All'],
|
||||
],
|
||||
value: 'recent-1',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Select options can include separators using Menu.line.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDisabledKeys: Story = {
|
||||
args: {
|
||||
options: [
|
||||
['draft', 'Draft'],
|
||||
['pending', 'Pending'],
|
||||
['approved', 'Approved'],
|
||||
['archived', 'Archived'],
|
||||
],
|
||||
value: 'draft',
|
||||
disabledKeys: ['approved', 'archived'],
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Certain options can be disabled using the disabledKeys prop.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const BareVariant: Story = {
|
||||
args: {
|
||||
bare: true,
|
||||
options: [
|
||||
['day', 'Day'],
|
||||
['week', 'Week'],
|
||||
['month', 'Month'],
|
||||
],
|
||||
value: 'month',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The bare variant renders the select without a bordered button style.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
options: [
|
||||
['opt1', 'Option 1'],
|
||||
['opt2', 'Option 2'],
|
||||
],
|
||||
value: 'opt1',
|
||||
disabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A disabled select that cannot be interacted with.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Controlled: Story = {
|
||||
args: {
|
||||
options: [
|
||||
['usd', 'USD - US Dollar'],
|
||||
['eur', 'EUR - Euro'],
|
||||
['gbp', 'GBP - British Pound'],
|
||||
['jpy', 'JPY - Japanese Yen'],
|
||||
],
|
||||
value: 'usd',
|
||||
},
|
||||
render: function Render() {
|
||||
const [value, setValue] = useState('usd');
|
||||
|
||||
return (
|
||||
<View style={{ gap: 10, alignItems: 'flex-start' }}>
|
||||
<Select
|
||||
options={[
|
||||
['usd', 'USD - US Dollar'],
|
||||
['eur', 'EUR - Euro'],
|
||||
['gbp', 'GBP - British Pound'],
|
||||
['jpy', 'JPY - Japanese Yen'],
|
||||
]}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<span>Selected: {value}</span>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A controlled select with external state management.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, type CSSProperties } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { SvgExpandArrow } from './icons/v0';
|
||||
|
||||
140
packages/component-library/src/SpaceBetween.stories.tsx
Normal file
140
packages/component-library/src/SpaceBetween.stories.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { SpaceBetween } from './SpaceBetween';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/SpaceBetween',
|
||||
component: SpaceBetween,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
args: {
|
||||
style: {
|
||||
display: 'flex',
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof SpaceBetween>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const Box = ({ children }: { children: string }) => (
|
||||
<View
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#e0e0e0',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
export const Horizontal: Story = {
|
||||
args: {
|
||||
direction: 'horizontal',
|
||||
children: (
|
||||
<>
|
||||
<Box>Item 1</Box>
|
||||
<Box>Item 2</Box>
|
||||
<Box>Item 3</Box>
|
||||
</>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'SpaceBetween lays out children horizontally with even spacing by default.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Vertical: Story = {
|
||||
args: {
|
||||
direction: 'vertical',
|
||||
children: (
|
||||
<>
|
||||
<Box>Item 1</Box>
|
||||
<Box>Item 2</Box>
|
||||
<Box>Item 3</Box>
|
||||
</>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Items laid out vertically with default spacing.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomGap: Story = {
|
||||
args: {
|
||||
direction: 'horizontal',
|
||||
gap: 30,
|
||||
children: (
|
||||
<>
|
||||
<Box>Gap 30</Box>
|
||||
<Box>Gap 30</Box>
|
||||
<Box>Gap 30</Box>
|
||||
</>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Custom gap between items.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoWrap: Story = {
|
||||
args: {
|
||||
direction: 'horizontal',
|
||||
wrap: false,
|
||||
children: (
|
||||
<>
|
||||
<Box>No Wrap</Box>
|
||||
<Box>No Wrap</Box>
|
||||
<Box>No Wrap</Box>
|
||||
<Box>No Wrap</Box>
|
||||
</>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Items will not wrap to the next line when wrap is false.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithButtons: Story = {
|
||||
args: {
|
||||
direction: 'horizontal',
|
||||
gap: 10,
|
||||
children: (
|
||||
<>
|
||||
<Button variant="bare">Cancel</Button>
|
||||
<Button variant="primary">Save</Button>
|
||||
</>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A common use case: spacing action buttons.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { type ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import type { CSSProperties } from './styles';
|
||||
import { View } from './View';
|
||||
|
||||
type SpaceBetweenProps = {
|
||||
|
||||
112
packages/component-library/src/Text.stories.tsx
Normal file
112
packages/component-library/src/Text.stories.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Text',
|
||||
component: Text,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Text>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'This is a text element',
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic Text component renders as a span element.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithStyle: Story = {
|
||||
args: {
|
||||
children: 'Styled text',
|
||||
style: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
color: '#1a73e8',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Text can accept custom styles via the style prop.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FontSizes: Story = {
|
||||
render: () => (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ fontSize: 12 }}>Small (12px)</Text>
|
||||
<Text style={{ fontSize: 14 }}>Default (14px)</Text>
|
||||
<Text style={{ fontSize: 18 }}>Medium (18px)</Text>
|
||||
<Text style={{ fontSize: 24 }}>Large (24px)</Text>
|
||||
<Text style={{ fontSize: 32 }}>Extra Large (32px)</Text>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Text at various font sizes.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FontWeights: Story = {
|
||||
render: () => (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ fontWeight: 300 }}>Light (300)</Text>
|
||||
<Text style={{ fontWeight: 400 }}>Normal (400)</Text>
|
||||
<Text style={{ fontWeight: 500 }}>Medium (500)</Text>
|
||||
<Text style={{ fontWeight: 600 }}>Semi Bold (600)</Text>
|
||||
<Text style={{ fontWeight: 700 }}>Bold (700)</Text>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Text at various font weights.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InlineUsage: Story = {
|
||||
render: () => (
|
||||
<View>
|
||||
<span>
|
||||
This is regular text with{' '}
|
||||
<Text style={{ fontWeight: 'bold', color: '#d32f2f' }}>
|
||||
highlighted
|
||||
</Text>{' '}
|
||||
and{' '}
|
||||
<Text style={{ fontStyle: 'italic', color: '#1a73e8' }}>
|
||||
emphasized
|
||||
</Text>{' '}
|
||||
portions.
|
||||
</span>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Text renders as a span, making it suitable for inline styling within other text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,13 +1,9 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import type { HTMLProps, ReactNode, Ref } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type TextProps = HTMLProps<HTMLSpanElement> & {
|
||||
innerRef?: Ref<HTMLSpanElement>;
|
||||
|
||||
105
packages/component-library/src/TextOneLine.stories.tsx
Normal file
105
packages/component-library/src/TextOneLine.stories.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { TextOneLine } from './TextOneLine';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/TextOneLine',
|
||||
component: TextOneLine,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof TextOneLine>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children:
|
||||
'This is a single line of text that will be truncated with an ellipsis if it overflows its container',
|
||||
style: { maxWidth: 300 },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'TextOneLine truncates overflowing text with an ellipsis, keeping content to a single line.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ShortText: Story = {
|
||||
args: {
|
||||
children: 'Short text',
|
||||
style: { maxWidth: 300 },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'When text fits within the container, no truncation occurs.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NarrowContainer: Story = {
|
||||
args: {
|
||||
children:
|
||||
'This text will be truncated because the container is very narrow',
|
||||
style: { maxWidth: 120 },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A narrow container forces earlier truncation.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ComparisonWithText: Story = {
|
||||
render: () => (
|
||||
<View style={{ gap: 15, maxWidth: 200 }}>
|
||||
<View>
|
||||
<strong>TextOneLine:</strong>
|
||||
<TextOneLine>
|
||||
This is a long piece of text that should be truncated
|
||||
</TextOneLine>
|
||||
</View>
|
||||
<View>
|
||||
<strong>Regular span:</strong>
|
||||
<span>This is a long piece of text that will wrap normally</span>
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Comparison between TextOneLine (truncated) and regular text (wrapping).',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomStyle: Story = {
|
||||
args: {
|
||||
children: 'Bold truncated text in a constrained container',
|
||||
style: {
|
||||
maxWidth: 200,
|
||||
fontWeight: 'bold',
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'TextOneLine with additional custom styles applied.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ComponentProps } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { Text } from './Text';
|
||||
|
||||
|
||||
11
packages/component-library/src/Themes/Theming.mdx
Normal file
11
packages/component-library/src/Themes/Theming.mdx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Themes/Theming" />
|
||||
|
||||
# Theming
|
||||
|
||||
Actual Budget supports customizable themes that allow you to personalize the look and feel of the application. You can switch between built-in themes or create your own custom themes.
|
||||
|
||||
For detailed information on how to create and apply custom themes, please visit the official documentation:
|
||||
|
||||
**[Custom Themes Documentation](https://actualbudget.org/docs/experimental/custom-themes)**
|
||||
150
packages/component-library/src/Toggle.stories.tsx
Normal file
150
packages/component-library/src/Toggle.stories.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { Toggle } from './Toggle';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Toggle',
|
||||
component: Toggle,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Toggle>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Off: Story = {
|
||||
args: {
|
||||
id: 'toggle-off',
|
||||
isOn: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Toggle in the off state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const On: Story = {
|
||||
args: {
|
||||
id: 'toggle-on',
|
||||
isOn: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Toggle in the on state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
id: 'toggle-disabled',
|
||||
isOn: false,
|
||||
isDisabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A disabled toggle that cannot be interacted with.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledOn: Story = {
|
||||
args: {
|
||||
id: 'toggle-disabled-on',
|
||||
isOn: true,
|
||||
isDisabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A disabled toggle in the on state.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
id: 'toggle-interactive',
|
||||
isOn: false,
|
||||
},
|
||||
render: function Render() {
|
||||
const [isOn, setIsOn] = useState(false);
|
||||
|
||||
return (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Toggle id="toggle-interactive" isOn={isOn} onToggle={setIsOn} />
|
||||
<Text>{isOn ? 'Enabled' : 'Disabled'}</Text>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'An interactive toggle with state feedback.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabels: Story = {
|
||||
args: {
|
||||
id: 'toggle-labels',
|
||||
isOn: false,
|
||||
},
|
||||
render: function Render() {
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const [autoSave, setAutoSave] = useState(true);
|
||||
|
||||
return (
|
||||
<View style={{ gap: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Toggle
|
||||
id="toggle-notifications"
|
||||
isOn={notifications}
|
||||
onToggle={setNotifications}
|
||||
/>
|
||||
<Text>Notifications</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Toggle
|
||||
id="toggle-dark-mode"
|
||||
isOn={darkMode}
|
||||
onToggle={setDarkMode}
|
||||
/>
|
||||
<Text>Dark Mode</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
||||
<Toggle
|
||||
id="toggle-auto-save"
|
||||
isOn={autoSave}
|
||||
onToggle={setAutoSave}
|
||||
/>
|
||||
<Text>Auto Save</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Multiple toggles in a settings-style layout.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type CSSProperties } from 'react';
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
|
||||
135
packages/component-library/src/Tooltip.stories.tsx
Normal file
135
packages/component-library/src/Tooltip.stories.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Button } from './Button';
|
||||
import { Text } from './Text';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Tooltip',
|
||||
component: Tooltip,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Tooltip>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
content: 'This is a tooltip',
|
||||
children: <Button>Hover me</Button>,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A basic tooltip displayed on hover after a short delay.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTextTrigger: Story = {
|
||||
args: {
|
||||
content: 'More information about this term',
|
||||
children: (
|
||||
<Text style={{ textDecoration: 'underline', cursor: 'help' }}>
|
||||
Hover for details
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'A tooltip triggered by hovering over text.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const RichContent: Story = {
|
||||
args: {
|
||||
content: (
|
||||
<View style={{ padding: 5, maxWidth: 200 }}>
|
||||
<Text style={{ fontWeight: 'bold' }}>Tip</Text>
|
||||
<Text>
|
||||
You can use keyboard shortcuts to navigate faster through the
|
||||
application.
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
children: <Button>Rich Tooltip</Button>,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Tooltip content can include rich React elements.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomPlacement: Story = {
|
||||
args: {
|
||||
content: 'Tooltip',
|
||||
children: <></>,
|
||||
},
|
||||
render: () => (
|
||||
<View style={{ gap: 10, display: 'flex', flexDirection: 'row' }}>
|
||||
<Tooltip content="Top placement" placement="top">
|
||||
<Button>Top</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Bottom placement" placement="bottom">
|
||||
<Button>Bottom</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Left placement" placement="left">
|
||||
<Button>Left</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Right placement" placement="right">
|
||||
<Button>Right</Button>
|
||||
</Tooltip>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'Tooltips can be placed in different positions around the trigger.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const DisabledTooltip: Story = {
|
||||
args: {
|
||||
content: 'You should not see this',
|
||||
children: <Button>Hover me (disabled)</Button>,
|
||||
triggerProps: { isDisabled: true },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'A tooltip can be disabled via triggerProps, preventing it from appearing.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomDelay: Story = {
|
||||
args: {
|
||||
content: 'This tooltip appears after 1 second',
|
||||
children: <Button>Slow Tooltip</Button>,
|
||||
triggerProps: { delay: 1000 },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'The delay before the tooltip appears can be customized.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,11 +1,5 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { Tooltip as AriaTooltip, TooltipTrigger } from 'react-aria-components';
|
||||
|
||||
import { styles } from './styles';
|
||||
|
||||
215
packages/component-library/src/View.stories.tsx
Normal file
215
packages/component-library/src/View.stories.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { Text } from './Text';
|
||||
import { View } from './View';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/View',
|
||||
component: View,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof View>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'A basic View container',
|
||||
style: { padding: 20, backgroundColor: '#f5f5f5', borderRadius: 4 },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'View is the fundamental layout building block, rendering a styled div element.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FlexRow: Story = {
|
||||
render: () => (
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
padding: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
padding: 15,
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Item 1
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
padding: 15,
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Item 2
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
padding: 15,
|
||||
backgroundColor: '#fff3e0',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Item 3
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Views arranged in a horizontal row using flexDirection.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const FlexColumn: Story = {
|
||||
render: () => (
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
padding: 10,
|
||||
backgroundColor: '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{ padding: 15, backgroundColor: '#e3f2fd', borderRadius: 4 }}
|
||||
>
|
||||
Row 1
|
||||
</View>
|
||||
<View
|
||||
style={{ padding: 15, backgroundColor: '#e8f5e9', borderRadius: 4 }}
|
||||
>
|
||||
Row 2
|
||||
</View>
|
||||
<View
|
||||
style={{ padding: 15, backgroundColor: '#fff3e0', borderRadius: 4 }}
|
||||
>
|
||||
Row 3
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Views stacked vertically in a column layout.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Nested: Story = {
|
||||
render: () => (
|
||||
<View
|
||||
style={{
|
||||
padding: 15,
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontWeight: 'bold', marginBottom: 10 }}>Parent View</Text>
|
||||
<View
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 10,
|
||||
backgroundColor: '#e3f2fd',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Child 1 (flex: 1)
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 2,
|
||||
padding: 10,
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
Child 2 (flex: 2)
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Nested Views demonstrating flex layout composition.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithNativeStyle: Story = {
|
||||
args: {
|
||||
children: 'View with nativeStyle',
|
||||
nativeStyle: {
|
||||
padding: '20px',
|
||||
border: '2px dashed #999',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
'The nativeStyle prop applies styles directly via the style attribute instead of using Emotion CSS.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CenteredContent: Story = {
|
||||
render: () => (
|
||||
<View
|
||||
style={{
|
||||
width: 300,
|
||||
height: 200,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f5f5f5',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
>
|
||||
<Text>Centered Content</Text>
|
||||
</View>
|
||||
),
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'View used to center content both horizontally and vertically.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { forwardRef, type HTMLProps, type Ref } from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import type { HTMLProps, Ref } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { type CSSProperties } from './styles';
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type ViewProps = HTMLProps<HTMLDivElement> & {
|
||||
className?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { type SVGProps } from 'react';
|
||||
import React from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
import { css, keyframes } from '@emotion/css';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, type SVGProps } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export const SvgLoading = (props: SVGProps<SVGSVGElement>) => {
|
||||
const { color = 'currentColor' } = props;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Config } from '@svgr/core';
|
||||
import type { Config } from '@svgr/core';
|
||||
|
||||
const tmpl: Config['template'] = (
|
||||
{ imports, interfaces, componentName, props, jsx },
|
||||
|
||||
@@ -12,8 +12,7 @@ const shadowLarge = {
|
||||
boxShadow: '0 15px 30px 0 rgba(0,0,0,0.11), 0 5px 15px 0 rgba(0,0,0,0.08)',
|
||||
};
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
export const styles: Record<string, any> = {
|
||||
export const styles: CSSProperties = {
|
||||
incomeHeaderHeight: 70,
|
||||
cardShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
|
||||
monthRightPadding: 5,
|
||||
|
||||
15
packages/component-library/tsconfig.json
Normal file
15
packages/component-library/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
const resolveExtensions = [
|
||||
'.testing.ts',
|
||||
'.web.ts',
|
||||
'.mjs',
|
||||
'.js',
|
||||
'.mts',
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build:node": "tsc --p tsconfig.dist.json",
|
||||
"build:node": "tsc",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node",
|
||||
"test": "vitest --run"
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
@@ -24,6 +25,6 @@
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.16"
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// * Need to check to make sure if account exists when handling
|
||||
// * transaction changes in syncing
|
||||
|
||||
import { type Timestamp } from './timestamp';
|
||||
import type { Timestamp } from './timestamp';
|
||||
|
||||
/**
|
||||
* Represents a node within a trinary radix trie.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import murmurhash from 'murmurhash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { type TrieNode } from './merkle';
|
||||
import type { TrieNode } from './merkle';
|
||||
|
||||
/**
|
||||
* Hybrid Unique Logical Clock (HULC) timestamp generator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
@@ -8,8 +9,10 @@
|
||||
"moduleResolution": "node10",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"strict": true,
|
||||
"outDir": "dist"
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user