From f29997e206a85a6caf7798cb0d0693b62fb16bce Mon Sep 17 00:00:00 2001 From: morre Date: Fri, 28 Feb 2025 13:30:33 +0100 Subject: [PATCH] feat: add match rules (#1875) This adds support for basic match rule editing. Comfort features like re-parsing transactions during the import and adding match rules from a running import will be added at a later date. --- .github/workflows/workflow.yml | 4 +- cypress/e2e/matchRules.cy.ts | 97 ++++++++++ cypress/e2e/settings.cy.ts | 22 +++ package-lock.json | 43 +++++ package.json | 3 + src/App.tsx | 11 ++ src/components/Autocomplete.tsx | 13 +- src/components/FormField.tsx | 8 +- src/components/MatchRuleList.tsx | 294 +++++++++++++++++++++++++++++++ src/components/Modal.tsx | 5 +- src/components/Notification.tsx | 6 +- src/components/Settings.tsx | 137 +++++++++++++- src/translations/en.json | 17 ++ src/types.d.ts | 8 + 14 files changed, 650 insertions(+), 18 deletions(-) create mode 100644 cypress/e2e/matchRules.cy.ts create mode 100644 cypress/e2e/settings.cy.ts create mode 100644 src/components/MatchRuleList.tsx diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 28c053ea..d083323b 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -156,7 +156,7 @@ jobs: with: platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} - outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.repository_owner == 'envelope-zero' && contains(github.ref, 'refs/tags/') }} + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.repository_owner == 'envelope-zero' && (contains(github.ref, 'refs/tags/') || contains(github.event.pull_request.labels.*.name, 'push-image')) }} build-args: | VITE_VERSION=${{ github.ref_name }} @@ -175,7 +175,7 @@ jobs: retention-days: 1 merge: - if: github.repository_owner == 'envelope-zero' && contains(github.ref, 'refs/tags/') + if: github.repository_owner == 'envelope-zero' && (contains(github.ref, 'refs/tags/') || contains(github.event.pull_request.labels.*.name, 'push-image')) runs-on: ubuntu-latest needs: - build diff --git a/cypress/e2e/matchRules.cy.ts b/cypress/e2e/matchRules.cy.ts new file mode 100644 index 00000000..2e2d6e08 --- /dev/null +++ b/cypress/e2e/matchRules.cy.ts @@ -0,0 +1,97 @@ +import { createAccount, createBudget } from '../support/setup' + +describe('Match Rule: invalid rule', () => { + beforeEach(() => { + // prepare & select a budget + cy.wrap(createBudget({ name: 'Invalid Match Rule Test' })).then(budget => { + cy.wrap(budget).as('budget') + cy.visit('/').get('li').contains('Open').click() + cy.contains('Settings').click() + cy.getByTitle('Edit match rules').first().click() + cy.awaitLoading() + }) + }) + + it('displays an error for an invalid rule', () => { + cy.getByTitle('Add match rule').first().click() + cy.getInputFor('Match').first().type('Some string') + cy.getByTitle('Save').first().click() + + cy.contains( + 'The rule with match "Some string" and account "" is invalid. Both match and account need to be set.' + ) + }) +}) + +describe('Match Rule: Creation and Deletion', () => { + beforeEach(() => { + // prepare & select a budget + cy.wrap(createBudget({ name: 'Match Rule Test' })).then(budget => { + cy.wrap(budget).as('budget') + cy.visit('/').get('li').contains('Open').click() + }) + }) + + it('can edit match rules', function () { + cy.wrap( + Cypress.Promise.all([ + createAccount( + { name: 'The Grocery Store', external: true }, + this.budget + ), + createAccount( + { name: 'My Favorite Restaurant', external: true }, + this.budget + ), + ]) + ) + + cy.contains('Settings').click() + cy.getByTitle('Edit match rules').first().click() + cy.awaitLoading() + + cy.getByTitle('Add match rule').first().click() + cy.getInputFor('Match').first().type('Restaurant*') + cy.getAutocompleteFor('Account').first().type('My Favorite') + cy.contains('My Favorite Rest').click() + + cy.getByTitle('Add match rule').first().click() + cy.getInputFor('Match').first().type('Groceries*') + cy.getAutocompleteFor('Account').first().type('Grocery') + cy.contains('The Grocery').click() + + cy.getByTitle('Save').first().click() + cy.contains('Changes saved successfully') + .parent() + .siblings() + .get('[title=Close]') + .click() + + // Delete the first matchRule and then check that only one exists still + cy.get('[title=Delete]').first().click({ force: true }) + cy.get('[name=match]').should('have.length', 1) + + // FIXME: If we don't have this, the "Changes saved" banner does not appear again + // I think that has to do with the transition, but not sure. + // Needs to be fixed before merging this or in a follow-up. + cy.wait(3000) + + cy.getByTitle('Save').first().click() + cy.contains('Changes saved successfully') + .parent() + .siblings() + .get('[title=Close]') + .click() + + // Reload the page to verify that the deletion has been carried out against the backend + cy.reload() + cy.get('#loading').should('exist') + cy.awaitLoading() + + // Verify that only one match rule exists. This means the deletion was successful in the backend + cy.get('[name=match]').should('have.length', 1) + + // Verify that correct match rule still exists + cy.getInputFor('Match').should('have.value', 'Restaurant*') + }) +}) diff --git a/cypress/e2e/settings.cy.ts b/cypress/e2e/settings.cy.ts new file mode 100644 index 00000000..bd97998b --- /dev/null +++ b/cypress/e2e/settings.cy.ts @@ -0,0 +1,22 @@ +import { createBudget } from '../support/setup' + +describe('Settings', () => { + beforeEach(() => { + // prepare & select a budget + cy.wrap(createBudget({ name: 'Settings Test' })).then(budget => { + cy.wrap(budget).as('budget') + cy.visit('/').get('li').contains('Open').click() + cy.get('a').contains('Settings').click() + cy.awaitLoading() + }) + }) + + it('displays the discard prompt when navigating to match rules', () => { + cy.getInputFor('Note').type( + "This project is anti fascist. Don't like it? Then piss off, we don't like you either." + ) + cy.getByTitle('Edit match rules').click() + + cy.contains('Discard unsaved changes?') + }) +}) diff --git a/package-lock.json b/package-lock.json index 6060685b..0f325696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/react-dom": "18.3.5", "@typescript-eslint/eslint-plugin": "8.25.0", "@typescript-eslint/parser": "8.25.0", + "@types/sortablejs": "1.15.8", "@vitejs/plugin-react": "4.3.4", "autoprefixer": "10.4.20", "buffer": "6.0.3", @@ -42,6 +43,8 @@ "react-i18next": "15.4.1", "react-router-dom": "6.30.0", "sass": "1.85.1", + "react-sortablejs": "6.1.4", + "sortablejs": "^1.15.6", "stream-browserify": "3.0.0", "tailwindcss": "3.4.17", "typescript": "5.7.3", @@ -3923,6 +3926,12 @@ "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz", "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==" }, + "node_modules/@types/sortablejs": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.8.tgz", + "integrity": "sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==", + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", @@ -9811,6 +9820,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-sortablejs": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.1.4.tgz", + "integrity": "sha512-fc7cBosfhnbh53Mbm6a45W+F735jwZ1UFIYSrIqcO/gRIFoDyZeMtgKlpV4DdyQfbCzdh5LoALLTDRxhMpTyXQ==", + "license": "MIT", + "dependencies": { + "classnames": "2.3.1", + "tiny-invariant": "1.2.0" + }, + "peerDependencies": { + "@types/sortablejs": "1", + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "sortablejs": "1" + } + }, + "node_modules/react-sortablejs/node_modules/classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==", + "license": "MIT" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -10437,6 +10468,12 @@ "node": ">=8.0.0" } }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -10902,6 +10939,12 @@ "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" }, + "node_modules/tiny-invariant": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.2.0.tgz", + "integrity": "sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg==", + "license": "MIT" + }, "node_modules/tldts": { "version": "6.1.64", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.64.tgz", diff --git a/package.json b/package.json index b231278f..84522665 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@types/react-dom": "18.3.5", "@typescript-eslint/eslint-plugin": "8.25.0", "@typescript-eslint/parser": "8.25.0", + "@types/sortablejs": "^1.15.8", "@vitejs/plugin-react": "4.3.4", "autoprefixer": "10.4.20", "buffer": "6.0.3", @@ -37,6 +38,8 @@ "react-i18next": "15.4.1", "react-router-dom": "6.30.0", "sass": "1.85.1", + "react-sortablejs": "^6.1.4", + "sortablejs": "^1.15.6", "stream-browserify": "3.0.0", "tailwindcss": "3.4.17", "typescript": "5.7.3", diff --git a/src/App.tsx b/src/App.tsx index f7645b34..ffa063bf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import BudgetForm from './components/BudgetForm' import OwnAccountsList from './components/OwnAccountsList' import ExternalAccountsList from './components/ExternalAccountsList' import AccountForm from './components/AccountForm' +import MatchRuleList from './components/MatchRuleList' import TransactionsList from './components/TransactionsList' import TransactionForm from './components/TransactionForm' import EnvelopesList from './components/EnvelopesList' @@ -195,6 +196,16 @@ const App = () => { /> } /> + + + } + /> )} } /> diff --git a/src/components/Autocomplete.tsx b/src/components/Autocomplete.tsx index 3ac34f5c..ae992d9a 100644 --- a/src/components/Autocomplete.tsx +++ b/src/components/Autocomplete.tsx @@ -24,13 +24,6 @@ type Props = { inputWrapperClass?: string } -const valueOrDefault = (customValue: any, defaultValue: any) => { - if (typeof customValue === 'undefined') { - return defaultValue - } - return customValue -} - const Autocomplete = ({ groups, label, @@ -84,12 +77,10 @@ const Autocomplete = ({ disabled={disabled} className={wrapperClass ?? ''} > - + {label} -
+
setQuery(event.target.value)} diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 9c6c4194..46f304f4 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -13,6 +13,8 @@ type Props = { note?: string tooltip?: string className?: string + inputWrapperClass?: string + labelClass?: string } const FormField = ({ @@ -27,6 +29,8 @@ const FormField = ({ note, tooltip, className, + inputWrapperClass, + labelClass, }: Props) => { if (type === 'number' && typeof options?.step === 'undefined') { options = { ...options, step: 'any' } @@ -34,7 +38,7 @@ const FormField = ({ return (
-