Skip to content

Commit

Permalink
feat: add match rules (#1875)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
morremeyer authored Feb 28, 2025
1 parent bea62fa commit f29997e
Show file tree
Hide file tree
Showing 14 changed files with 650 additions and 18 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
Expand Down
97 changes: 97 additions & 0 deletions cypress/e2e/matchRules.cy.ts
Original file line number Diff line number Diff line change
@@ -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*')
})
})
22 changes: 22 additions & 0 deletions cypress/e2e/settings.cy.ts
Original file line number Diff line number Diff line change
@@ -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?')
})
})
43 changes: 43 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -195,6 +196,16 @@ const App = () => {
/>
}
/>

<Route
path="/settings/match-rules"
element={
<MatchRuleList
budget={budget}
setNotification={setNotification}
/>
}
/>
</>
)}
<Route path="*" element={<Navigate to="/" replace />} />
Expand Down
13 changes: 2 additions & 11 deletions src/components/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,6 @@ type Props<T> = {
inputWrapperClass?: string
}

const valueOrDefault = (customValue: any, defaultValue: any) => {
if (typeof customValue === 'undefined') {
return defaultValue
}
return customValue
}

const Autocomplete = <T extends ArchivableResource>({
groups,
label,
Expand Down Expand Up @@ -84,12 +77,10 @@ const Autocomplete = <T extends ArchivableResource>({
disabled={disabled}
className={wrapperClass ?? ''}
>
<Combobox.Label
className={valueOrDefault(labelClass, 'form-field--label')}
>
<Combobox.Label className={labelClass ?? 'form-field--label'}>
{label}
</Combobox.Label>
<div className={`${valueOrDefault(inputWrapperClass, 'input--outer')}`}>
<div className={inputWrapperClass ?? 'input--outer'}>
<Combobox.Input
className="input"
onChange={event => setQuery(event.target.value)}
Expand Down
8 changes: 6 additions & 2 deletions src/components/FormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type Props = {
note?: string
tooltip?: string
className?: string
inputWrapperClass?: string
labelClass?: string
}

const FormField = ({
Expand All @@ -27,14 +29,16 @@ const FormField = ({
note,
tooltip,
className,
inputWrapperClass,
labelClass,
}: Props) => {
if (type === 'number' && typeof options?.step === 'undefined') {
options = { ...options, step: 'any' }
}

return (
<div className={className || ''}>
<label htmlFor={name} className="form-field--label">
<label htmlFor={name} className={labelClass ?? 'form-field--label'}>
<span className="flex items-center">
{label}
{typeof tooltip !== 'undefined' ? (
Expand All @@ -55,7 +59,7 @@ const FormField = ({
</span>
</label>

<div className={'input--outer sm:col-span-2'}>
<div className={inputWrapperClass ?? 'input--outer sm:col-span-2'}>
<input
className="input"
type={type}
Expand Down
Loading

0 comments on commit f29997e

Please sign in to comment.