From 5339b98560fa9333500c9cc0cda96f238f69db30 Mon Sep 17 00:00:00 2001 From: Morre Date: Fri, 7 Feb 2025 22:24:17 +0100 Subject: [PATCH] feat: add match rules 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. --- cypress/e2e/matchRules.cy.ts | 62 +++++++ package-lock.json | 43 +++++ package.json | 3 + src/App.tsx | 11 ++ src/components/Autocomplete.tsx | 8 +- src/components/FormField.tsx | 17 +- src/components/MatchRuleList.tsx | 255 ++++++++++++++++++++++++++ src/components/Settings.tsx | 18 ++ src/components/TransactionFilters.tsx | 1 + src/lib/prop-helper.ts | 9 + src/translations/en.json | 11 ++ src/types.d.ts | 9 + 12 files changed, 438 insertions(+), 9 deletions(-) create mode 100644 cypress/e2e/matchRules.cy.ts create mode 100644 src/components/MatchRuleList.tsx create mode 100644 src/lib/prop-helper.ts diff --git a/cypress/e2e/matchRules.cy.ts b/cypress/e2e/matchRules.cy.ts new file mode 100644 index 00000000..0dbeca84 --- /dev/null +++ b/cypress/e2e/matchRules.cy.ts @@ -0,0 +1,62 @@ +import { createAccount, createBudget } from '../support/setup' + +describe('Account: Creation', () => { + 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').click() + cy.contains('Changes saved successfully') + + // FIXME: Does not click/delete yet + cy.contains('Delete').first().click({ force: true }) + cy.contains('Groceries*').should('not.exist') + + cy.getByTitle('Save').click() + cy.contains('Changes saved successfully') + + cy.reload() + // We need to wait for a second so that the awaitLoading can actually + // see the loading spinner and wait for it to disappear + cy.wait(1000) + cy.awaitLoading() + + // Verify that only one match rule exists + cy.getInputFor('Match').should('have.length', 1) + + // Verify that correct match rule still exists + cy.getInputFor('Match').first().should('have.value', 'Restaurant*') + }) +}) diff --git a/package-lock.json b/package-lock.json index 3d319e35..d64f337b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@types/node": "22.13.1", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", + "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "8.23.0", "@typescript-eslint/parser": "8.23.0", "@vitejs/plugin-react": "4.3.4", @@ -41,7 +42,9 @@ "react-dom": "18.3.1", "react-i18next": "15.3.0", "react-router-dom": "6.28.2", + "react-sortablejs": "^6.1.4", "sass": "1.84.0", + "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", @@ -9839,6 +9848,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", @@ -10465,6 +10496,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", @@ -10930,6 +10967,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 562c2de7..bdea7399 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "@types/node": "22.13.1", "@types/react": "18.3.18", "@types/react-dom": "18.3.5", + "@types/sortablejs": "^1.15.8", "@typescript-eslint/eslint-plugin": "8.23.0", "@typescript-eslint/parser": "8.23.0", "@vitejs/plugin-react": "4.3.4", @@ -36,7 +37,9 @@ "react-dom": "18.3.1", "react-i18next": "15.3.0", "react-router-dom": "6.28.2", + "react-sortablejs": "^6.1.4", "sass": "1.84.0", + "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..199a0833 100644 --- a/src/components/Autocomplete.tsx +++ b/src/components/Autocomplete.tsx @@ -4,6 +4,7 @@ import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid' import { Combobox } from '@headlessui/react' import { useTranslation } from 'react-i18next' import { ArchiveBoxIcon } from '@heroicons/react/24/outline' +import { valueOrDefault } from '../lib/prop-helper' function classNames(...classes: (string | boolean)[]) { return classes.filter(Boolean).join(' ') @@ -24,13 +25,6 @@ type Props = { inputWrapperClass?: string } -const valueOrDefault = (customValue: any, defaultValue: any) => { - if (typeof customValue === 'undefined') { - return defaultValue - } - return customValue -} - const Autocomplete = ({ groups, label, diff --git a/src/components/FormField.tsx b/src/components/FormField.tsx index 9c6c4194..0b2b415a 100644 --- a/src/components/FormField.tsx +++ b/src/components/FormField.tsx @@ -1,5 +1,6 @@ import { QuestionMarkCircleIcon } from '@heroicons/react/20/solid' import { Tooltip } from 'flowbite-react' +import { valueOrDefault } from '../lib/prop-helper' type Props = { type: string @@ -13,6 +14,8 @@ type Props = { note?: string tooltip?: string className?: string + inputWrapperClass?: string + labelClass?: string } const FormField = ({ @@ -27,6 +30,8 @@ const FormField = ({ note, tooltip, className, + inputWrapperClass, + labelClass, }: Props) => { if (type === 'number' && typeof options?.step === 'undefined') { options = { ...options, step: 'any' } @@ -34,7 +39,10 @@ const FormField = ({ return (
-