Compare commits

..

14 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
da33183c11 Initial plan 2026-02-09 19:19:12 +00:00
Joel Jeremy Marquez
7f2f22f00b Merge branch 'master' into react-query-accounts 2026-02-08 10:14:35 -08:00
Joel Jeremy Marquez
4a37e7aefc [skip ci] Change category to Maintenance and update migration text 2026-02-07 01:06:35 +00:00
Joel Jeremy Marquez
6be6d1066e Fix onbudget and offbudget displaying closed accounts 2026-02-07 01:06:35 +00:00
Joel Jeremy Marquez
119679cf1b Fix TestProviders 2026-02-07 01:06:35 +00:00
autofix-ci[bot]
51e85d3369 [autofix.ci] apply automated fixes 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
ae9b559c84 Cleanup 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
a66071ccbd Coderabbit feedback 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
36c97bbb90 Fix TestProviders 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
f081a47d86 Fix lint error 2026-02-07 01:06:34 +00:00
github-actions[bot]
9d852fdae0 Add release notes for PR #6140 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
e4724e50cf TestProviders 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
49c8ad7224 Move redux state to react-query - account states 2026-02-07 01:06:34 +00:00
Joel Jeremy Marquez
a070b4a6df Fix typecheck errors 2026-02-07 01:00:20 +00:00
1073 changed files with 9324 additions and 15836 deletions

View File

@@ -14,7 +14,7 @@ reviews:
enabled: false
labeling_instructions:
- label: 'suspect ai generated'
instructions: 'This issue or PR is suspected to be generated by AI. Add this only if "AI generated" label is not present. Add it always if the commit or PR title is prefixed with "[AI]".'
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'

View File

@@ -1,21 +1 @@
<!-- 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 --->

View File

@@ -74,4 +74,4 @@ async function checkReleaseNotesExists() {
}
}
void checkReleaseNotesExists();
checkReleaseNotesExists();

View File

@@ -74,4 +74,4 @@ async function commentOnPR() {
}
}
void commentOnPR();
commentOnPR();

View File

@@ -94,4 +94,4 @@ ${summaryData.summary}
}
}
void createReleaseNotesFile();
createReleaseNotesFile();

View File

@@ -8,13 +8,6 @@ const CONFIG = {
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4, // Awarded to whoever merges the release PR
PR_CONTRIBUTION_POINTS: {
Features: 2,
Enhancements: 2,
Bugfix: 3,
Maintenance: 2,
Unknown: 2,
},
// Point tiers for code changes (non-docs)
CODE_PR_REVIEW_POINT_TIERS: [
{ minChanges: 500, points: 8 },
@@ -38,116 +31,6 @@ 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);
if (category && CONFIG.PR_CONTRIBUTION_POINTS[category]) {
return {
category,
points: CONFIG.PR_CONTRIBUTION_POINTS[category],
};
}
}
} catch {
// Do nothing
}
return {
category: 'Unknown',
points: CONFIG.PR_CONTRIBUTION_POINTS.Unknown,
};
}
/**
* Get the start and end dates for the last month.
* @returns {Object} An object containing the start and end dates.
@@ -206,7 +89,6 @@ 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,
@@ -320,28 +202,6 @@ 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 (
@@ -433,7 +293,7 @@ async function countContributorPoints() {
// Print all statistics
printStats(
'Code Review Statistics',
stats => stats.codeReviews.reduce((sum, r) => sum + r.points, 0),
stats => stats.codeReviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
@@ -448,7 +308,7 @@ async function countContributorPoints() {
printStats(
'Docs Review Statistics',
stats => stats.docsReviews.reduce((sum, r) => sum + r.points, 0),
stats => stats.docsReviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
@@ -456,27 +316,16 @@ 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 * CONFIG.POINTS_PER_ISSUE_TRIAGE_ACTION,
stats => stats.labelRemovals.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
);
printStats(
'Issue Closing Statistics',
stats =>
stats.issueClosings.length * CONFIG.POINTS_PER_ISSUE_CLOSING_ACTION,
stats => stats.issueClosings.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
);

View File

@@ -30,7 +30,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
@@ -53,7 +53,7 @@ jobs:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
@@ -81,7 +81,7 @@ jobs:
matrix:
shard: [1, 2, 3, 4, 5]
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
@@ -104,7 +104,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment

View File

@@ -199,7 +199,6 @@ jobs:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
branch: 'release/${{ needs.build.outputs.version }}'
draft: true
title: 'Update Actual flatpak to version ${{ needs.build.outputs.version }}'
body: |
This PR updates the Actual desktop flatpak to version ${{ needs.build.outputs.version }}.

View File

@@ -44,7 +44,7 @@ jobs:
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
image: mcr.microsoft.com/playwright:v1.57.0-jammy
steps:
- name: Get PR details
id: pr

View File

@@ -18,15 +18,15 @@
"customGroups": [
{
"groupName": "react",
"elementNamePattern": ["react", "react-dom/*", "react-*"]
"elementNamePattern": ["react"]
},
{
"groupName": "loot-core",
"elementNamePattern": ["loot-core/**"]
"elementNamePattern": ["loot-core"]
},
{
"groupName": "desktop-client",
"elementNamePattern": ["@desktop-client/**"]
"elementNamePattern": ["@desktop-client"]
}
],
"newlinesBetween": true

View File

@@ -20,72 +20,72 @@
"rules": {
// Import sorting
"perfectionist/sort-named-imports": [
"error",
"warn",
{
"groups": ["value-import", "type-import"]
}
],
// Actual rules
"actual/typography": "error",
"actual/typography": "warn",
"actual/no-untranslated-strings": "error",
"actual/prefer-trans-over-t": "error",
"actual/prefer-if-statement": "error",
"actual/prefer-if-statement": "warn",
"actual/prefer-logger-over-console": "error",
"actual/object-shorthand-properties": "error",
"actual/prefer-const": "error",
"actual/no-anchor-tag": "error",
"actual/no-react-default-import": "error",
"actual/object-shorthand-properties": "warn",
"actual/prefer-const": "warn",
"actual/no-anchor-tag": "warn",
"actual/no-react-default-import": "warn",
// JSX A11y rules
"jsx-a11y/no-autofocus": [
"error",
"warn",
{
"ignoreNonDOM": true
}
],
"jsx-a11y/alt-text": "error",
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/alt-text": "warn",
"jsx-a11y/anchor-has-content": "warn",
"jsx-a11y/anchor-is-valid": [
"error",
"warn",
{
"aspects": ["noHref", "invalidHref"]
}
],
"jsx-a11y/aria-activedescendant-has-tabindex": "error",
"jsx-a11y/aria-props": "error",
"jsx-a11y/aria-proptypes": "error",
"jsx-a11y/aria-activedescendant-has-tabindex": "warn",
"jsx-a11y/aria-props": "warn",
"jsx-a11y/aria-proptypes": "warn",
"jsx-a11y/aria-role": [
"error",
"warn",
{
"ignoreNonDOM": true
}
],
"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",
"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",
// Typescript rules
"typescript/ban-ts-comment": ["error"],
"typescript/consistent-type-definitions": ["error", "type"],
"typescript/ban-ts-comment": ["warn"],
"typescript/consistent-type-definitions": ["warn", "type"],
"typescript/consistent-type-imports": [
"error",
"warn",
{
"prefer": "type-imports",
"fixStyle": "inline-type-imports"
}
],
"typescript/no-implied-eval": "error",
"typescript/no-explicit-any": "error",
"typescript/no-implied-eval": "warn",
"typescript/no-explicit-any": "warn",
"typescript/no-restricted-types": [
"error",
"warn",
{
"types": {
// forbid FC as superfluous
@@ -98,146 +98,141 @@
}
}
],
"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
"typescript/no-var-requires": "warn",
// Import rules
"import/consistent-type-specifier-style": "error",
"import/first": "error",
"import/no-amd": "error",
"import/no-default-export": "error",
"import/no-default-export": "warn",
"import/no-webpack-loader-syntax": "error",
"import/no-useless-path-segments": "error",
"import/no-unresolved": "error",
"import/no-unused-modules": "error",
"import/no-useless-path-segments": "warn",
"import/no-unresolved": "warn",
"import/no-unused-modules": "warn",
"import/no-duplicates": [
"error",
"warn",
{
"prefer-inline": false
"prefer-inline": true
}
],
// React rules
"react/exhaustive-deps": [
"error",
"warn",
{
"additionalHooks": "(^useQuery$|^useEffectAfterMount$)"
}
],
"react/jsx-curly-brace-presence": "error",
"react/jsx-curly-brace-presence": "warn",
"react/jsx-filename-extension": [
"error",
"warn",
{
"extensions": [".jsx", ".tsx"],
"allow": "as-needed"
}
],
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": "error",
"react/jsx-no-comment-textnodes": "warn",
"react/jsx-no-duplicate-props": "warn",
"react/jsx-no-target-blank": "warn",
"react/jsx-no-undef": "error",
"react/jsx-no-useless-fragment": "error",
"react/jsx-no-useless-fragment": "warn",
"react/jsx-pascal-case": [
"error",
"warn",
{
"allowAllCaps": true,
"ignore": []
}
],
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": "error",
"react/no-is-mounted": "error",
"react/no-unstable-nested-components": "error",
"react/no-danger-with-children": "warn",
"react/no-direct-mutation-state": "warn",
"react/no-is-mounted": "warn",
"react/no-unstable-nested-components": "warn",
"react/require-render-return": "error",
"react/rules-of-hooks": "error",
"react/self-closing-comp": "error",
"react/style-prop-object": "error",
"react/jsx-boolean-value": "error",
"react/self-closing-comp": "warn",
"react/style-prop-object": "warn",
"react/jsx-boolean-value": "warn",
// ESLint rules
"eslint/array-callback-return": "error",
"eslint/curly": ["error", "multi-line", "consistent"],
"eslint/array-callback-return": "warn",
"eslint/curly": ["warn", "multi-line", "consistent"],
"eslint/default-case": [
"error",
"warn",
{
"commentPattern": "^no default$"
}
],
"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/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/no-labels": [
"error",
"warn",
{
"allowLoop": true,
"allowSwitch": false
}
],
"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-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-useless-rename": [
"error",
"warn",
{
"ignoreDestructuring": false,
"ignoreImport": false,
"ignoreExport": false
}
],
"eslint/no-with": "error",
"eslint/no-regex-spaces": "error",
"eslint/no-with": "warn",
"eslint/no-regex-spaces": "warn",
"eslint/no-restricted-globals": [
"error",
"warn",
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
"addEventListener",
@@ -299,7 +294,7 @@
"top"
],
"eslint/no-restricted-imports": [
"error",
"warn",
{
"paths": [
{
@@ -349,9 +344,9 @@
]
}
],
"eslint/no-useless-constructor": "error",
"eslint/no-undef": "error",
"eslint/no-unused-expressions": "error"
"eslint/no-useless-constructor": "warn",
"eslint/no-undef": "warn",
"eslint/no-unused-expressions": "warn"
},
"overrides": [
{
@@ -394,12 +389,6 @@
"typescript-paths/absolute-import": ["error", { "enableAlias": false }]
}
},
{
"files": ["packages/desktop-client/src/style/themes/*"],
"rules": {
"eslint/no-restricted-imports": "off"
}
},
// TODO: enable these
{
"files": [

View File

@@ -42,28 +42,6 @@ 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
**THIS IS A MANDATORY REQUIREMENT THAT MUST BE FOLLOWED WITHOUT EXCEPTION:**
- **ALL commit messages MUST be prefixed with `[AI]`**
- **ALL pull request titles MUST be prefixed with `[AI]`**
**Examples:**
-`[AI] Fix type error in account validation`
-`[AI] Add support for new transaction categories`
-`Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
-`Add support for new transaction categories` (MISSING PREFIX - NOT ALLOWED)
**This requirement applies to:**
- Every single commit message created by AI agents
- Every single pull request title created by AI agents
- No exceptions are permitted
**This is a hard requirement that agents MUST follow. Failure to include the `[AI]` prefix is a violation of these instructions.**
### 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:
@@ -360,8 +338,6 @@ Always maintain newlines between import groups.
**Git Commands:**
- **MANDATORY: ALL commit messages MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- **MANDATORY: ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement with no exceptions
- Never update git config
- Never run destructive git operations (force push, hard reset) unless explicitly requested
- Never skip hooks (--no-verify, --no-gpg-sign)
@@ -565,7 +541,6 @@ Icons in `packages/component-library/src/icons/` are auto-generated. Don't manua
Before committing changes, ensure:
- [ ] **MANDATORY: Commit message is prefixed with `[AI]`** - This is a hard requirement with no exceptions
- [ ] `yarn typecheck` passes
- [ ] `yarn lint:fix` has been run
- [ ] Relevant tests pass
@@ -580,16 +555,8 @@ Before committing changes, ensure:
When creating pull requests:
- **MANDATORY PREFIX REQUIREMENT**: **ALL pull request titles MUST be prefixed with `[AI]`** - This is a hard requirement that MUST be followed without exception
- ✅ Correct: `[AI] Fix type error in account validation`
- ❌ Incorrect: `Fix type error in account validation` (MISSING PREFIX - NOT ALLOWED)
- **AI-Generated PRs**: If you create a PR using AI assistance, add the **"AI generated"** label to the pull request. This helps maintainers understand the nature of the contribution.
### PR Template: Do Not Fill In
- **NEVER fill in the PR template** (`.github/PULL_REQUEST_TEMPLATE.md`). Leave all blank spaces and placeholder comments as-is. We expect **humans** to fill in the Description, Related issue(s), Testing, and Checklist sections.
- **Exception**: If a human **explicitly asks** you to fill out the PR template, then fill it out **in Chinese**, using Chinese characters (简体中文) for all content you add.
## Code Review Guidelines
When performing code reviews (especially for LLM agents): **see [CODE_REVIEW_GUIDELINES.md](./CODE_REVIEW_GUIDELINES.md)** for specific guidelines.

View File

@@ -178,4 +178,4 @@ async function execAsync(cmd: string, errorLog?: string): Promise<string> {
});
}
void run();
run();

View File

@@ -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.58.2-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -1,9 +1,6 @@
/** @type {import('lage').ConfigOptions} */
module.exports = {
pipeline: {
typecheck: {
type: 'npmScript',
},
test: {
type: 'npmScript',
options: {

View File

@@ -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 --type-aware",
"lint:fix": "oxfmt . && oxlint --fix --type-aware",
"lint": "oxfmt --check . && oxlint --deny-warnings",
"lint:fix": "oxfmt . && oxlint --deny-warnings --fix",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "tsc -p tsconfig.root.json --noEmit && lage typecheck",
"typecheck": "yarn tsc --incremental && tsc-strict",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.10",
"@types/node": "^22.19.3",
"@types/prompts": "^2.4.9",
"baseline-browser-mapping": "^2.9.19",
"baseline-browser-mapping": "^2.9.14",
"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.17",
"lage": "^2.14.15",
"lint-staged": "^16.2.7",
"minimatch": "^10.1.2",
"minimatch": "^10.1.1",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.32.0",
"oxlint": "^1.47.0",
"oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0",
"oxfmt": "^0.26.0",
"oxlint": "^1.41.0",
"p-limit": "^7.2.0",
"prompts": "^2.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
"rollup": "4.40.1",
@@ -95,7 +95,7 @@
"oxfmt --no-error-on-unmatched-pattern"
],
"*.{js,mjs,jsx,ts,tsx}": [
"oxlint --fix --type-aware"
"oxlint --deny-warnings --fix"
]
},
"browserslist": [

View File

@@ -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,143 +356,6 @@ 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' });

View File

@@ -5,7 +5,6 @@ import type {
APIFileEntity,
APIPayeeEntity,
APIScheduleEntity,
APITagEntity,
} from 'loot-core/server/api-models';
import type { Query } from 'loot-core/shared/query';
import type { Handlers } from 'loot-core/types/handlers';
@@ -275,25 +274,6 @@ 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'][],

View File

@@ -1,6 +1,6 @@
{
"name": "@actual-app/api",
"version": "26.2.1",
"version": "26.2.0",
"description": "An API for Actual",
"license": "MIT",
"files": [
@@ -12,17 +12,16 @@
"scripts": {
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"build:migrations": "mkdir dist/migrations && cp migrations/*.sql dist/migrations",
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
"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",
"typecheck": "yarn build && tsc --noEmit && tsc-strict"
"clean": "rm -rf dist @types"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^12.6.2",
"better-sqlite3": "^12.5.0",
"compare-versions": "^6.1.1",
"node-fetch": "^3.3.2",
"uuid": "^13.0.0"
@@ -30,8 +29,7 @@
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.9.3",
"typescript-strict-plugin": "^2.4.4",
"vitest": "^4.0.18"
"vitest": "^4.0.16"
},
"engines": {
"node": ">=20"

View File

@@ -12,9 +12,8 @@
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
},
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
}
},
"include": [".", "../../packages/loot-core/typings/pegjs.ts"],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

View File

@@ -6,6 +6,6 @@
"test": "vitest --run"
},
"devDependencies": {
"vitest": "^4.0.18"
"vitest": "^4.0.16"
}
}

View File

@@ -13,8 +13,7 @@ function getAbsolutePath(value: string) {
}
const config: StorybookConfig = {
stories: [
'../src/Concepts/*.mdx',
'../src/Themes/*.mdx',
'../src/Introduction.mdx',
'../src/**/*.mdx',
'../src/**/*.stories.@(js|jsx|mjs|ts|tsx)',
],

View File

@@ -1,99 +0,0 @@
<!--
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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1 +0,0 @@
/* Custom Storybook Styling */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -39,29 +39,28 @@
"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",
"typecheck": "tsc --noEmit"
"build:storybook": "storybook build"
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.15.1",
"react-aria-components": "^1.14.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "^10.2.7",
"@storybook/addon-docs": "^10.2.7",
"@storybook/react-vite": "^10.2.7",
"@storybook/addon-a11y": "^10.2.0",
"@storybook/addon-docs": "^10.2.0",
"@storybook/react-vite": "^10.2.0",
"@svgr/cli": "^8.1.0",
"@types/react": "^19.2.5",
"eslint-plugin-storybook": "^10.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.7",
"vitest": "^4.0.18"
"eslint-plugin-storybook": "^10.2.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"storybook": "^10.2.0",
"vitest": "^4.0.16"
},
"peerDependencies": {
"react": ">=19.2",
"react-dom": ">=19.2"
"react": ">=18.2",
"react-dom": ">=18.2"
}
}

View File

@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
import { AlignedText } from './AlignedText';
const meta = {
title: 'Components/AlignedText',
title: 'AlignedText',
component: AlignedText,
parameters: {
layout: 'centered',

View File

@@ -1,4 +1,4 @@
import type { ComponentProps, CSSProperties, ReactNode } from 'react';
import { type ComponentProps, type CSSProperties, type ReactNode } from 'react';
import { Block } from './Block';
import { View } from './View';

View File

@@ -4,7 +4,7 @@ import { Block } from './Block';
import { theme } from './theme';
const meta = {
title: 'Components/Block',
title: 'Block',
component: Block,
parameters: {
layout: 'centered',

View File

@@ -1,8 +1,8 @@
import type { HTMLProps, Ref } from 'react';
import { type HTMLProps, type 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>;

View File

@@ -4,7 +4,7 @@ import { fn } from 'storybook/test';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
title: 'Button',
component: Button,
parameters: {
layout: 'centered',

View File

@@ -1,5 +1,10 @@
import React, { forwardRef, useMemo } from 'react';
import type { ComponentPropsWithoutRef, CSSProperties, ReactNode } from 'react';
import React, {
forwardRef,
useMemo,
type ComponentPropsWithoutRef,
type CSSProperties,
type ReactNode,
} from 'react';
import { Button as ReactAriaButton } from 'react-aria-components';
import { css, cx } from '@emotion/css';

View File

@@ -6,7 +6,7 @@ import { Paragraph } from './Paragraph';
import { theme } from './theme';
const meta = {
title: 'Components/Card',
title: 'Card',
component: Card,
parameters: {
layout: 'centered',

View File

@@ -1,5 +1,4 @@
import { forwardRef } from 'react';
import type { ComponentProps } from 'react';
import { forwardRef, type ComponentProps } from 'react';
import { theme } from './theme';
import { View } from './View';

View File

@@ -8,7 +8,7 @@ import { Button } from './Button';
import { ColorPicker } from './ColorPicker';
const meta = {
title: 'Components/ColorPicker',
title: 'ColorPicker',
component: ColorPicker,
parameters: {
layout: 'centered',

View File

@@ -1,4 +1,4 @@
import type { ChangeEvent, ReactNode } from 'react';
import { type ChangeEvent, type ReactNode } from 'react';
import {
ColorPicker as AriaColorPicker,
ColorSwatch as AriaColorSwatch,
@@ -8,10 +8,8 @@ import {
Dialog,
DialogTrigger,
parseColor,
} from 'react-aria-components';
import type {
ColorPickerProps as AriaColorPickerProps,
ColorSwatchProps,
type ColorPickerProps as AriaColorPickerProps,
type ColorSwatchProps,
} from 'react-aria-components';
import { css } from '@emotion/css';

View File

@@ -5,7 +5,7 @@ import { Input } from './Input';
import { View } from './View';
const meta = {
title: 'Components/FormError',
title: 'FormError',
component: FormError,
parameters: {
layout: 'centered',

View File

@@ -1,4 +1,4 @@
import type { CSSProperties, ReactNode } from 'react';
import { type CSSProperties, type ReactNode } from 'react';
import { View } from './View';

View File

@@ -1,86 +0,0 @@
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.',
},
},
},
};

View File

@@ -4,8 +4,10 @@ import {
isValidElement,
useEffect,
useRef,
type ReactElement,
type Ref,
type RefObject,
} from 'react';
import type { ReactElement, Ref, RefObject } from 'react';
type InitialFocusProps<T extends HTMLElement> = {
/**

View File

@@ -1,6 +1,5 @@
import * as React from 'react';
import { forwardRef } from 'react';
import type { Ref } from 'react';
import { forwardRef, type Ref } from 'react';
import { render } from '@testing-library/react';

View File

@@ -1,101 +0,0 @@
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.',
},
},
},
};

View File

@@ -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;

View File

@@ -1,215 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,9 +1,8 @@
import React from 'react';
import type {
ChangeEvent,
ComponentPropsWithRef,
FocusEvent,
KeyboardEvent,
import React, {
type ChangeEvent,
type ComponentPropsWithRef,
type FocusEvent,
type KeyboardEvent,
} from 'react';
import { Input as ReactAriaInput } from 'react-aria-components';

View File

@@ -1,6 +1,6 @@
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Concepts/Introduction" />
<Meta title="Introduction" />
# Actual Budget Component Library

View File

@@ -1,97 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,5 +1,4 @@
import { forwardRef } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import { forwardRef, type CSSProperties, type ReactNode } from 'react';
import { styles } from './styles';
import { Text } from './Text';

View File

@@ -1,243 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,11 +1,13 @@
import { useEffect, useRef, useState } from 'react';
import type {
ComponentProps,
ComponentType,
CSSProperties,
KeyboardEvent,
ReactNode,
SVGProps,
import {
useEffect,
useRef,
useState,
type ComponentProps,
type ComponentType,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
type SVGProps,
} from 'react';
import { Button } from './Button';

View File

@@ -1,134 +0,0 @@
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.',
},
},
},
};

View File

@@ -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;

View File

@@ -1,153 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef } from 'react';
import type { ComponentProps } from 'react';
import { useCallback, useEffect, useRef, type ComponentProps } from 'react';
import { Popover as ReactAriaPopover } from 'react-aria-components';
import { css } from '@emotion/css';

View File

@@ -1,178 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,5 +1,4 @@
import { useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { useRef, useState, type CSSProperties } from 'react';
import { Button } from './Button';
import { SvgExpandArrow } from './icons/v0';

View File

@@ -1,140 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,7 +1,6 @@
import React from 'react';
import type { ReactNode } from 'react';
import React, { type ReactNode } from 'react';
import type { CSSProperties } from './styles';
import { type CSSProperties } from './styles';
import { View } from './View';
type SpaceBetweenProps = {

View File

@@ -1,112 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,9 +1,13 @@
import React, { forwardRef } from 'react';
import type { HTMLProps, ReactNode, Ref } from 'react';
import React, {
forwardRef,
type HTMLProps,
type ReactNode,
type 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>;

View File

@@ -1,105 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,4 +1,4 @@
import type { ComponentProps } from 'react';
import { type ComponentProps } from 'react';
import { Text } from './Text';

View File

@@ -1,11 +0,0 @@
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)**

View File

@@ -1,150 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,5 +1,4 @@
import React from 'react';
import type { CSSProperties } from 'react';
import React, { type CSSProperties } from 'react';
import { css } from '@emotion/css';

View File

@@ -1,135 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,5 +1,11 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { ComponentProps, ReactNode } from 'react';
import React, {
useCallback,
useEffect,
useRef,
useState,
type ComponentProps,
type ReactNode,
} from 'react';
import { Tooltip as AriaTooltip, TooltipTrigger } from 'react-aria-components';
import { styles } from './styles';

View File

@@ -1,215 +0,0 @@
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.',
},
},
},
};

View File

@@ -1,9 +1,8 @@
import React, { forwardRef } from 'react';
import type { HTMLProps, Ref } from 'react';
import React, { forwardRef, type HTMLProps, type 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;

View File

@@ -1,5 +1,4 @@
import React from 'react';
import type { SVGProps } from 'react';
import React, { type SVGProps } from 'react';
import { css, keyframes } from '@emotion/css';

View File

@@ -1,5 +1,4 @@
import React, { useState } from 'react';
import type { SVGProps } from 'react';
import React, { useState, type SVGProps } from 'react';
export const SvgLoading = (props: SVGProps<SVGSVGElement>) => {
const { color = 'currentColor' } = props;

View File

@@ -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 },

View File

@@ -12,7 +12,8 @@ const shadowLarge = {
boxShadow: '0 15px 30px 0 rgba(0,0,0,0.11), 0 5px 15px 0 rgba(0,0,0,0.08)',
};
export const styles: CSSProperties = {
// oxlint-disable-next-line typescript/no-explicit-any
export const styles: Record<string, any> = {
incomeHeaderHeight: 70,
cardShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
monthRightPadding: 5,

View File

@@ -1,10 +0,0 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "src",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}

View File

@@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/config';
const resolveExtensions = [
'.testing.ts',
'.web.ts',
'.mjs',
'.js',
'.mts',

View File

@@ -9,11 +9,10 @@
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build:node": "tsc",
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --run",
"typecheck": "tsc --noEmit"
"test": "vitest --run"
},
"dependencies": {
"google-protobuf": "^3.21.4",
@@ -25,6 +24,6 @@
"protoc-gen-js": "3.21.4-4",
"ts-protoc-gen": "0.15.0",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
"vitest": "^4.0.16"
}
}

View File

@@ -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.

View File

@@ -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

View File

@@ -65,10 +65,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.57.0-jammy /bin/bash
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
# Use the ip and port noted earlier

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
@@ -46,7 +46,7 @@ test.describe('Mobile Accounts', () => {
await expect(accountPage.heading).toHaveText('Bank of America');
await expect(accountPage.transactionList).toBeVisible();
expect(await accountPage.getBalance()).toBeGreaterThan(0);
await expect(await accountPage.getBalance()).toBeGreaterThan(0);
await expect(accountPage.noTransactionsMessage).not.toBeVisible();
await expect(page).toMatchThemeScreenshots();

View File

@@ -1,9 +1,9 @@
import { join } from 'path';
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import type { AccountPage } from './page-models/account-page';
import { type AccountPage } from './page-models/account-page';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';
@@ -158,7 +158,9 @@ test.describe('Accounts', () => {
await expect(page).toMatchThemeScreenshots();
await expect(importButton).toBeDisabled();
expect(await importButton.innerText()).toMatch(/Import 0 transactions/);
await expect(await importButton.innerText()).toMatch(
/Import 0 transactions/,
);
await accountPage.page.getByRole('button', { name: 'Close' }).click();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -1,8 +1,8 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import type { MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
import { type MobileBankSyncPage } from './page-models/mobile-bank-sync-page';
import { MobileNavigation } from './page-models/mobile-navigation';
test.describe('Mobile Bank Sync', () => {

View File

@@ -1,7 +1,7 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import type { BankSyncPage } from './page-models/bank-sync-page';
import { type BankSyncPage } from './page-models/bank-sync-page';
import { ConfigurationPage } from './page-models/configuration-page';
import { Navigation } from './page-models/navigation';

View File

@@ -1,11 +1,11 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import * as monthUtils from 'loot-core/shared/months';
import { amountToCurrency, currencyToAmount } from 'loot-core/shared/util';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';
import type { MobileBudgetPage } from './page-models/mobile-budget-page';
import { type MobileBudgetPage } from './page-models/mobile-budget-page';
import { MobileNavigation } from './page-models/mobile-navigation';
const copyLastMonthBudget = async (

View File

@@ -1,7 +1,7 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import type { BudgetPage } from './page-models/budget-page';
import { type BudgetPage } from './page-models/budget-page';
import { ConfigurationPage } from './page-models/configuration-page';
test.describe('Budget', () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test';
import { type Page } from '@playwright/test';
import { expect, test } from './fixtures';
import { ConfigurationPage } from './page-models/configuration-page';

Some files were not shown because too many files have changed in this diff Show More