diff --git a/.github/workflows/electron-master.yml b/.github/workflows/electron-master.yml index a7511042871..0f85e408899 100644 --- a/.github/workflows/electron-master.yml +++ b/.github/workflows/electron-master.yml @@ -40,6 +40,7 @@ jobs: python3 -m pip install setuptools - if: ${{ startsWith(matrix.os, 'ubuntu') }} run: | + sudo apt-get update sudo apt-get install flatpak -y sudo apt-get install flatpak-builder -y sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo diff --git a/.github/workflows/electron-pr.yml b/.github/workflows/electron-pr.yml index bc8ae13dba5..1bc63bb50c3 100644 --- a/.github/workflows/electron-pr.yml +++ b/.github/workflows/electron-pr.yml @@ -35,6 +35,7 @@ jobs: python3 -m pip install setuptools - if: ${{ startsWith(matrix.os, 'ubuntu') }} run: | + sudo apt-get update sudo apt-get install flatpak -y sudo apt-get install flatpak-builder -y sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index 7994eddc673..2c379293cea 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -19,7 +19,7 @@ import { getFieldError, unparse, FIELD_TYPES, - TYPE_INFO, + getValidOps, } from 'loot-core/src/shared/rules'; import { titleFirst } from 'loot-core/src/shared/util'; @@ -77,7 +77,7 @@ function ConfigureField({ }, [op]); const type = FIELD_TYPES.get(field); - let ops = TYPE_INFO[type].ops.filter(op => op !== 'isbetween'); + let ops = getValidOps(field).filter(op => op !== 'isbetween'); // Month and year fields are quite hacky right now! Figure out how // to clean this up later @@ -259,7 +259,7 @@ export function FilterButton({ onApply, compact, hover, exclude }) { case 'configure': { const { field } = deserializeField(action.field); const type = FIELD_TYPES.get(field); - const ops = TYPE_INFO[type].ops; + const ops = getValidOps(field); return { ...state, fieldsOpen: false, diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 4f6286f8383..97721973312 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -11,7 +11,7 @@ export function updateFilterReducer( switch (action.type) { case 'set-op': { const type = FIELD_TYPES.get(state.field); - let value = state.value; + let value: RuleConditionEntity['value'] | null = state.value; if ( (type === 'id' || type === 'string') && (action.op === 'contains' || diff --git a/packages/desktop-client/src/components/modals/EditRuleModal.jsx b/packages/desktop-client/src/components/modals/EditRuleModal.jsx index ea6a8827935..db40fe13605 100644 --- a/packages/desktop-client/src/components/modals/EditRuleModal.jsx +++ b/packages/desktop-client/src/components/modals/EditRuleModal.jsx @@ -26,8 +26,9 @@ import { unparse, makeValue, FIELD_TYPES, - TYPE_INFO, ALLOCATION_METHODS, + isValidOp, + getValidOps, } from 'loot-core/src/shared/rules'; import { integerToCurrency, @@ -580,14 +581,15 @@ function ConditionsList({ if ( (prevType === 'string' || prevType === 'number') && prevType === newCond.type && - cond.op !== 'isbetween' + cond.op !== 'isbetween' && + isValidOp(newCond.field, cond.op) ) { // Don't clear the value & op if the type is string/number and // the type hasn't changed newCond.op = cond.op; return newInput(makeValue(cond.value, newCond)); } else { - newCond.op = TYPE_INFO[newCond.type].ops[0]; + newCond.op = getValidOps(newCond.field)[0]; return newInput(makeValue(null, newCond)); } } else if (field === 'op') { @@ -654,7 +656,7 @@ function ConditionsList({ ) : ( {conditions.map((cond, i) => { - let ops = TYPE_INFO[cond.type].ops; + let ops = getValidOps(cond.field); // Hack for now, these ops should be the only ones available // for recurring dates diff --git a/packages/desktop-client/src/components/util/LoadComponent.tsx b/packages/desktop-client/src/components/util/LoadComponent.tsx index 3a180373c57..cff5fe26ccf 100644 --- a/packages/desktop-client/src/components/util/LoadComponent.tsx +++ b/packages/desktop-client/src/components/util/LoadComponent.tsx @@ -34,6 +34,7 @@ function LoadComponentInner({ localModuleCache.get(name) ?? null, ); const [failedToLoad, setFailedToLoad] = useState(false); + const [failedToLoadException, setFailedToLoadException] = useState(null); useEffect(() => { if (localModuleCache.has(name)) { @@ -55,13 +56,14 @@ function LoadComponentInner({ { retries: 5, }, - ).catch(() => { + ).catch(e => { setFailedToLoad(true); + setFailedToLoadException(e); }); }, [name, importer]); if (failedToLoad) { - throw new LazyLoadFailedError(name); + throw new LazyLoadFailedError(name, failedToLoadException); } if (!Component) { diff --git a/packages/desktop-client/src/gocardless.ts b/packages/desktop-client/src/gocardless.ts index 3ae10e355d1..b9861626bcd 100644 --- a/packages/desktop-client/src/gocardless.ts +++ b/packages/desktop-client/src/gocardless.ts @@ -45,12 +45,14 @@ export async function authorizeBank( ) { _authorize(dispatch, upgradingAccountId, { onSuccess: async data => { - pushModal('select-linked-accounts', { - accounts: data.accounts, - requisitionId: data.id, - upgradingAccountId, - syncSource: 'goCardless', - }); + dispatch( + pushModal('select-linked-accounts', { + accounts: data.accounts, + requisitionId: data.id, + upgradingAccountId, + syncSource: 'goCardless', + }), + ); }, }); } diff --git a/packages/loot-core/src/server/accounts/rules.test.ts b/packages/loot-core/src/server/accounts/rules.test.ts index 392add3ff8a..00df2b74bf3 100644 --- a/packages/loot-core/src/server/accounts/rules.test.ts +++ b/packages/loot-core/src/server/accounts/rules.test.ts @@ -9,19 +9,6 @@ import { RuleIndexer, } from './rules'; -const fieldTypes = new Map( - Object.entries({ - id: 'id', - account: 'id', - date: 'date', - name: 'string', - category: 'string', - description: 'id', - description2: 'id', - amount: 'number', - }), -); - describe('Condition', () => { test('parses date formats correctly', () => { expect(parseDateString('2020-08-10')).toEqual({ @@ -44,35 +31,35 @@ describe('Condition', () => { }); test('ops handles null fields', () => { - let cond = new Condition('contains', 'name', 'foo', null, fieldTypes); - expect(cond.eval({ name: null })).toBe(false); + let cond = new Condition('contains', 'notes', 'foo', null); + expect(cond.eval({ notes: null })).toBe(false); - cond = new Condition('matches', 'name', '^fo*$', null, fieldTypes); - expect(cond.eval({ name: null })).toBe(false); + cond = new Condition('matches', 'notes', '^fo*$', null); + expect(cond.eval({ notes: null })).toBe(false); - cond = new Condition('oneOf', 'name', ['foo'], null, fieldTypes); - expect(cond.eval({ name: null })).toBe(false); + cond = new Condition('oneOf', 'notes', ['foo'], null); + expect(cond.eval({ notes: null })).toBe(false); ['gt', 'gte', 'lt', 'lte', 'isapprox'].forEach(op => { - const cond = new Condition(op, 'date', '2020-01-01', null, fieldTypes); + const cond = new Condition(op, 'date', '2020-01-01', null); expect(cond.eval({ date: null })).toBe(false); }); - cond = new Condition('is', 'id', null, null, fieldTypes); - expect(cond.eval({ id: null })).toBe(true); + cond = new Condition('is', 'payee', null, null); + expect(cond.eval({ payee: null })).toBe(true); }); test('ops handles undefined fields', () => { const spy = jest.spyOn(console, 'warn').mockImplementation(); - let cond = new Condition('is', 'id', null, null, fieldTypes); + let cond = new Condition('is', 'payee', null, null); // null is strict and won't match undefined - expect(cond.eval({ name: 'James' })).toBe(false); + expect(cond.eval({ notes: 'James' })).toBe(false); - cond = new Condition('contains', 'name', 'foo', null, fieldTypes); + cond = new Condition('contains', 'notes', 'foo', null); expect(cond.eval({ date: '2020-01-01' })).toBe(false); - cond = new Condition('matches', 'name', '^fo*$', null, fieldTypes); + cond = new Condition('matches', 'notes', '^fo*$', null); expect(cond.eval({ date: '2020-01-01' })).toBe(false); spy.mockRestore(); @@ -80,40 +67,40 @@ describe('Condition', () => { test('date restricts operators for each type', () => { expect(() => { - new Condition('isapprox', 'date', '2020-08', null, fieldTypes); + new Condition('isapprox', 'date', '2020-08', null); }).toThrow('Invalid date value for'); expect(() => { - new Condition('gt', 'date', '2020-08', null, fieldTypes); + new Condition('gt', 'date', '2020-08', null); }).toThrow('Invalid date value for'); expect(() => { - new Condition('gte', 'date', '2020-08', null, fieldTypes); + new Condition('gte', 'date', '2020-08', null); }).toThrow('Invalid date value for'); expect(() => { - new Condition('lt', 'date', '2020-08', null, fieldTypes); + new Condition('lt', 'date', '2020-08', null); }).toThrow('Invalid date value for'); expect(() => { - new Condition('lte', 'date', '2020-08', null, fieldTypes); + new Condition('lte', 'date', '2020-08', null); }).toThrow('Invalid date value for'); }); test('date conditions work with `is` operator', () => { - let cond = new Condition('is', 'date', '2020-08-10', null, fieldTypes); + let cond = new Condition('is', 'date', '2020-08-10', null); expect(cond.eval({ date: '2020-08-05' })).toBe(false); expect(cond.eval({ date: '2020-08-10' })).toBe(true); - cond = new Condition('is', 'date', '2020-08', null, fieldTypes); + cond = new Condition('is', 'date', '2020-08', null); expect(cond.eval({ date: '2020-08-05' })).toBe(true); expect(cond.eval({ date: '2020-08-10' })).toBe(true); expect(cond.eval({ date: '2020-09-10' })).toBe(false); - cond = new Condition('is', 'date', '2020', null, fieldTypes); + cond = new Condition('is', 'date', '2020', null); expect(cond.eval({ date: '2020-08-05' })).toBe(true); expect(cond.eval({ date: '2020-08-10' })).toBe(true); expect(cond.eval({ date: '2020-09-10' })).toBe(true); expect(cond.eval({ date: '2019-09-10' })).toBe(false); // Approximate dates - cond = new Condition('isapprox', 'date', '2020-08-07', null, fieldTypes); + cond = new Condition('isapprox', 'date', '2020-08-07', null); expect(cond.eval({ date: '2020-08-04' })).toBe(false); expect(cond.eval({ date: '2020-08-05' })).toBe(true); expect(cond.eval({ date: '2020-08-09' })).toBe(true); @@ -130,7 +117,6 @@ describe('Condition', () => { patterns: [{ type: 'day', value: 15 }], }, null, - fieldTypes, ); expect(cond.eval({ date: '2018-03-15' })).toBe(false); expect(cond.eval({ date: '2019-03-15' })).toBe(true); @@ -147,7 +133,6 @@ describe('Condition', () => { interval: 3, }, null, - fieldTypes, ); expect(cond.eval({ date: '2019-01-12' })).toBe(true); expect(cond.eval({ date: '2019-04-12' })).toBe(true); @@ -164,7 +149,6 @@ describe('Condition', () => { patterns: [{ type: 'day', value: 15 }], }, null, - fieldTypes, ); expect(cond.eval({ date: '2019-03-12' })).toBe(false); expect(cond.eval({ date: '2019-03-13' })).toBe(true); @@ -177,151 +161,134 @@ describe('Condition', () => { }); test('date conditions work with comparison operators', () => { - let cond = new Condition('gt', 'date', '2020-08-10', null, fieldTypes); + let cond = new Condition('gt', 'date', '2020-08-10', null); expect(cond.eval({ date: '2020-08-11' })).toBe(true); expect(cond.eval({ date: '2020-08-10' })).toBe(false); - cond = new Condition('gte', 'date', '2020-08-10', null, fieldTypes); + cond = new Condition('gte', 'date', '2020-08-10', null); expect(cond.eval({ date: '2020-08-11' })).toBe(true); expect(cond.eval({ date: '2020-08-10' })).toBe(true); expect(cond.eval({ date: '2020-08-09' })).toBe(false); - cond = new Condition('lt', 'date', '2020-08-10', null, fieldTypes); + cond = new Condition('lt', 'date', '2020-08-10', null); expect(cond.eval({ date: '2020-08-09' })).toBe(true); expect(cond.eval({ date: '2020-08-10' })).toBe(false); - cond = new Condition('lte', 'date', '2020-08-10', null, fieldTypes); + cond = new Condition('lte', 'date', '2020-08-10', null); expect(cond.eval({ date: '2020-08-09' })).toBe(true); expect(cond.eval({ date: '2020-08-10' })).toBe(true); expect(cond.eval({ date: '2020-08-11' })).toBe(false); }); test('id works with all operators', () => { - let cond = new Condition('is', 'id', 'foo', null, fieldTypes); - expect(cond.eval({ id: 'foo' })).toBe(true); - expect(cond.eval({ id: 'FOO' })).toBe(true); - expect(cond.eval({ id: 'foo2' })).toBe(false); - - cond = new Condition('oneOf', 'id', ['foo', 'bar'], null, fieldTypes); - expect(cond.eval({ id: 'foo' })).toBe(true); - expect(cond.eval({ id: 'FOO' })).toBe(true); - expect(cond.eval({ id: 'Bar' })).toBe(true); - expect(cond.eval({ id: 'bar2' })).toBe(false); + let cond = new Condition('is', 'payee', 'foo', null); + expect(cond.eval({ payee: 'foo' })).toBe(true); + expect(cond.eval({ payee: 'FOO' })).toBe(true); + expect(cond.eval({ payee: 'foo2' })).toBe(false); + + cond = new Condition('oneOf', 'payee', ['foo', 'bar'], null); + expect(cond.eval({ payee: 'foo' })).toBe(true); + expect(cond.eval({ payee: 'FOO' })).toBe(true); + expect(cond.eval({ payee: 'Bar' })).toBe(true); + expect(cond.eval({ payee: 'bar2' })).toBe(false); }); test('string works with all operators', () => { - let cond = new Condition('is', 'name', 'foo', null, fieldTypes); - expect(cond.eval({ name: 'foo' })).toBe(true); - expect(cond.eval({ name: 'FOO' })).toBe(true); - expect(cond.eval({ name: 'foo2' })).toBe(false); - - cond = new Condition('oneOf', 'name', ['foo', 'bar'], null, fieldTypes); - expect(cond.eval({ name: 'foo' })).toBe(true); - expect(cond.eval({ name: 'FOO' })).toBe(true); - expect(cond.eval({ name: 'Bar' })).toBe(true); - expect(cond.eval({ name: 'bar2' })).toBe(false); - - cond = new Condition('contains', 'name', 'foo', null, fieldTypes); - expect(cond.eval({ name: 'bar foo baz' })).toBe(true); - expect(cond.eval({ name: 'bar FOOb' })).toBe(true); - expect(cond.eval({ name: 'foo' })).toBe(true); - expect(cond.eval({ name: 'foob' })).toBe(true); - expect(cond.eval({ name: 'bfoo' })).toBe(true); - expect(cond.eval({ name: 'bfo' })).toBe(false); - expect(cond.eval({ name: 'f o o' })).toBe(false); - - cond = new Condition('matches', 'name', '^fo*$', null, fieldTypes); - expect(cond.eval({ name: 'bar foo baz' })).toBe(false); - expect(cond.eval({ name: 'bar FOOb' })).toBe(false); - expect(cond.eval({ name: 'foo' })).toBe(true); - expect(cond.eval({ name: 'foob' })).toBe(false); - expect(cond.eval({ name: 'bfoo' })).toBe(false); - expect(cond.eval({ name: 'bfo' })).toBe(false); - expect(cond.eval({ name: 'f o o' })).toBe(false); + let cond = new Condition('is', 'notes', 'foo', null); + expect(cond.eval({ notes: 'foo' })).toBe(true); + expect(cond.eval({ notes: 'FOO' })).toBe(true); + expect(cond.eval({ notes: 'foo2' })).toBe(false); + + cond = new Condition('oneOf', 'notes', ['foo', 'bar'], null); + expect(cond.eval({ notes: 'foo' })).toBe(true); + expect(cond.eval({ notes: 'FOO' })).toBe(true); + expect(cond.eval({ notes: 'Bar' })).toBe(true); + expect(cond.eval({ notes: 'bar2' })).toBe(false); + + cond = new Condition('contains', 'notes', 'foo', null); + expect(cond.eval({ notes: 'bar foo baz' })).toBe(true); + expect(cond.eval({ notes: 'bar FOOb' })).toBe(true); + expect(cond.eval({ notes: 'foo' })).toBe(true); + expect(cond.eval({ notes: 'foob' })).toBe(true); + expect(cond.eval({ notes: 'bfoo' })).toBe(true); + expect(cond.eval({ notes: 'bfo' })).toBe(false); + expect(cond.eval({ notes: 'f o o' })).toBe(false); + + cond = new Condition('matches', 'notes', '^fo*$', null); + expect(cond.eval({ notes: 'bar foo baz' })).toBe(false); + expect(cond.eval({ notes: 'bar FOOb' })).toBe(false); + expect(cond.eval({ notes: 'foo' })).toBe(true); + expect(cond.eval({ notes: 'FOOOO' })).toBe(true); + expect(cond.eval({ notes: 'foob' })).toBe(false); + expect(cond.eval({ notes: 'bfoo' })).toBe(false); + expect(cond.eval({ notes: 'bfo' })).toBe(false); + expect(cond.eval({ notes: 'f o o' })).toBe(false); }); test('matches handles invalid regex', () => { - const cond = new Condition('matches', 'name', 'fo**', null, fieldTypes); - expect(cond.eval({ name: 'foo' })).toBe(false); + const cond = new Condition('matches', 'notes', 'fo**', null); + expect(cond.eval({ notes: 'foo' })).toBe(false); }); test('number validates value', () => { - new Condition('isapprox', 'amount', 34, null, fieldTypes); + new Condition('isapprox', 'amount', 34, null); expect(() => { - new Condition('isapprox', 'amount', 'hello', null, fieldTypes); + new Condition('isapprox', 'amount', 'hello', null); }).toThrow('Value must be a number or between amount'); expect(() => { - new Condition('is', 'amount', { num1: 0, num2: 10 }, null, fieldTypes); + new Condition('is', 'amount', { num1: 0, num2: 10 }, null); }).toThrow('Invalid number value for'); - new Condition( - 'isbetween', - 'amount', - { num1: 0, num2: 10 }, - null, - fieldTypes, - ); + new Condition('isbetween', 'amount', { num1: 0, num2: 10 }, null); expect(() => { - new Condition('isbetween', 'amount', 34.22, null, fieldTypes); + new Condition('isbetween', 'amount', 34.22, null); }).toThrow('Invalid between value for'); expect(() => { - new Condition('isbetween', 'amount', { num1: 0 }, null, fieldTypes); + new Condition('isbetween', 'amount', { num1: 0 }, null); }).toThrow('Value must be a number or between amount'); }); test('number works with all operators', () => { - let cond = new Condition('is', 'amount', 155, null, fieldTypes); + let cond = new Condition('is', 'amount', 155, null); expect(cond.eval({ amount: 155 })).toBe(true); expect(cond.eval({ amount: 167 })).toBe(false); - cond = new Condition('isapprox', 'amount', 1535, null, fieldTypes); + cond = new Condition('isapprox', 'amount', 1535, null); expect(cond.eval({ amount: 1540 })).toBe(true); expect(cond.eval({ amount: 1300 })).toBe(false); expect(cond.eval({ amount: 1650 })).toBe(true); expect(cond.eval({ amount: 1800 })).toBe(false); - cond = new Condition( - 'isbetween', - 'amount', - { num1: 32, num2: 86 }, - null, - fieldTypes, - ); + cond = new Condition('isbetween', 'amount', { num1: 32, num2: 86 }, null); expect(cond.eval({ amount: 30 })).toBe(false); expect(cond.eval({ amount: 32 })).toBe(true); expect(cond.eval({ amount: 80 })).toBe(true); expect(cond.eval({ amount: 86 })).toBe(true); expect(cond.eval({ amount: 90 })).toBe(false); - cond = new Condition( - 'isbetween', - 'amount', - { num1: -16, num2: -20 }, - null, - fieldTypes, - ); + cond = new Condition('isbetween', 'amount', { num1: -16, num2: -20 }, null); expect(cond.eval({ amount: -18 })).toBe(true); expect(cond.eval({ amount: -12 })).toBe(false); - cond = new Condition('gt', 'amount', 1.55, null, fieldTypes); + cond = new Condition('gt', 'amount', 1.55, null); expect(cond.eval({ amount: 1.55 })).toBe(false); expect(cond.eval({ amount: 1.67 })).toBe(true); expect(cond.eval({ amount: 1.5 })).toBe(false); - cond = new Condition('gte', 'amount', 1.55, null, fieldTypes); + cond = new Condition('gte', 'amount', 1.55, null); expect(cond.eval({ amount: 1.55 })).toBe(true); expect(cond.eval({ amount: 1.67 })).toBe(true); expect(cond.eval({ amount: 1.5 })).toBe(false); - cond = new Condition('lt', 'amount', 1.55, null, fieldTypes); + cond = new Condition('lt', 'amount', 1.55, null); expect(cond.eval({ amount: 1.55 })).toBe(false); expect(cond.eval({ amount: 1.67 })).toBe(false); expect(cond.eval({ amount: 1.5 })).toBe(true); - cond = new Condition('lte', 'amount', 1.55, null, fieldTypes); + cond = new Condition('lte', 'amount', 1.55, null); expect(cond.eval({ amount: 1.55 })).toBe(true); expect(cond.eval({ amount: 1.67 })).toBe(false); expect(cond.eval({ amount: 1.5 })).toBe(true); @@ -330,23 +297,23 @@ describe('Condition', () => { describe('Action', () => { test('`set` operator sets a field', () => { - const action = new Action('set', 'name', 'James', null, fieldTypes); - const item = { name: 'Sarah' }; + const action = new Action('set', 'notes', 'James', null); + const item = { notes: 'Sarah' }; action.exec(item); - expect(item.name).toBe('James'); + expect(item.notes).toBe('James'); expect(() => { - new Action('set', 'foo', 'James', null, new Map()); + new Action('set', 'foo', 'James', null); }).toThrow(/invalid field/i); expect(() => { - new Action(null, 'name', 'James', null, fieldTypes); + new Action(null, 'notes', 'James', null); }).toThrow(/invalid action operation/i); }); test('empty account values result in error', () => { expect(() => { - new Action('set', 'account', '', null, fieldTypes); + new Action('set', 'account', '', null); }).toThrow(/Field cannot be empty/i); }); }); @@ -355,44 +322,42 @@ describe('Rule', () => { test('executing a rule works', () => { let rule = new Rule({ conditionsOp: 'and', - conditions: [{ op: 'is', field: 'name', value: 'James' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + conditions: [{ op: 'is', field: 'notes', value: 'James' }], + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); // This matches - expect(rule.exec({ name: 'James' })).toEqual({ name: 'Sarah' }); + expect(rule.exec({ notes: 'James' })).toEqual({ notes: 'Sarah' }); // It returns updates, not the whole object - expect(rule.exec({ name: 'James', date: '2018-10-01' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James', date: '2018-10-01' })).toEqual({ + notes: 'Sarah', }); // This does not match - expect(rule.exec({ name: 'James2' })).toEqual(null); - expect(rule.apply({ name: 'James2' })).toEqual({ name: 'James2' }); + expect(rule.exec({ notes: 'James2' })).toEqual(null); + expect(rule.apply({ notes: 'James2' })).toEqual({ notes: 'James2' }); rule = new Rule({ conditionsOp: 'and', - conditions: [{ op: 'is', field: 'name', value: 'James' }], + conditions: [{ op: 'is', field: 'notes', value: 'James' }], actions: [ - { op: 'set', field: 'name', value: 'Sarah' }, + { op: 'set', field: 'notes', value: 'Sarah' }, { op: 'set', field: 'category', value: 'Sarah' }, ], - fieldTypes, }); - expect(rule.exec({ name: 'James' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James' })).toEqual({ + notes: 'Sarah', category: 'Sarah', }); - expect(rule.exec({ name: 'James2' })).toEqual(null); - expect(rule.apply({ name: 'James2' })).toEqual({ name: 'James2' }); + expect(rule.exec({ notes: 'James2' })).toEqual(null); + expect(rule.apply({ notes: 'James2' })).toEqual({ notes: 'James2' }); }); test('rule with `and` conditionsOp evaluates conditions as AND', () => { const rule = new Rule({ conditionsOp: 'and', conditions: [ - { op: 'is', field: 'name', value: 'James' }, + { op: 'is', field: 'notes', value: 'James' }, { op: 'isapprox', field: 'date', @@ -403,25 +368,24 @@ describe('Rule', () => { }, }, ], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); - expect(rule.exec({ name: 'James', date: '2018-01-12' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James', date: '2018-01-12' })).toEqual({ + notes: 'Sarah', }); - expect(rule.exec({ name: 'James2', date: '2018-01-12' })).toEqual(null); - expect(rule.exec({ name: 'James', date: '2018-01-10' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James2', date: '2018-01-12' })).toEqual(null); + expect(rule.exec({ notes: 'James', date: '2018-01-10' })).toEqual({ + notes: 'Sarah', }); - expect(rule.exec({ name: 'James', date: '2018-01-15' })).toEqual(null); + expect(rule.exec({ notes: 'James', date: '2018-01-15' })).toEqual(null); }); test('rule with `or` conditionsOp evaluates conditions as OR', () => { const rule = new Rule({ conditionsOp: 'or', conditions: [ - { op: 'is', field: 'name', value: 'James' }, + { op: 'is', field: 'notes', value: 'James' }, { op: 'isapprox', field: 'date', @@ -432,21 +396,20 @@ describe('Rule', () => { }, }, ], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); - expect(rule.exec({ name: 'James', date: '2018-01-12' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James', date: '2018-01-12' })).toEqual({ + notes: 'Sarah', }); - expect(rule.exec({ name: 'James2', date: '2018-01-12' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James2', date: '2018-01-12' })).toEqual({ + notes: 'Sarah', }); - expect(rule.exec({ name: 'James', date: '2018-01-10' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James', date: '2018-01-10' })).toEqual({ + notes: 'Sarah', }); - expect(rule.exec({ name: 'James', date: '2018-01-15' })).toEqual({ - name: 'Sarah', + expect(rule.exec({ notes: 'James', date: '2018-01-15' })).toEqual({ + notes: 'Sarah', }); }); @@ -457,29 +420,28 @@ describe('Rule', () => { conditionsOp: 'and', conditions, actions: [], - fieldTypes, }); const expectOrder = (rules, ids) => expect(rules.map(r => r.getId())).toEqual(ids); let rules = [ - rule('id1', [{ op: 'contains', field: 'name', value: 'sar' }]), - rule('id2', [{ op: 'contains', field: 'name', value: 'jim' }]), - rule('id3', [{ op: 'is', field: 'name', value: 'James' }]), + rule('id1', [{ op: 'contains', field: 'notes', value: 'sar' }]), + rule('id2', [{ op: 'contains', field: 'notes', value: 'jim' }]), + rule('id3', [{ op: 'is', field: 'notes', value: 'James' }]), ]; expectOrder(rankRules(rules), ['id1', 'id2', 'id3']); rules = [ - rule('id1', [{ op: 'contains', field: 'name', value: 'sar' }]), - rule('id2', [{ op: 'oneOf', field: 'name', value: ['jim', 'sar'] }]), - rule('id3', [{ op: 'is', field: 'name', value: 'James' }]), + rule('id1', [{ op: 'contains', field: 'notes', value: 'sar' }]), + rule('id2', [{ op: 'oneOf', field: 'notes', value: ['jim', 'sar'] }]), + rule('id3', [{ op: 'is', field: 'notes', value: 'James' }]), rule('id4', [ - { op: 'is', field: 'name', value: 'James' }, + { op: 'is', field: 'notes', value: 'James' }, { op: 'gt', field: 'amount', value: 5 }, ]), rule('id5', [ - { op: 'is', field: 'name', value: 'James' }, + { op: 'is', field: 'notes', value: 'James' }, { op: 'gt', field: 'amount', value: 5 }, { op: 'lt', field: 'amount', value: 10 }, ]), @@ -489,35 +451,33 @@ describe('Rule', () => { test('iterateIds finds all the ids', () => { const rule = (id, conditions, actions = []) => - new Rule({ id, conditionsOp: 'and', conditions, actions, fieldTypes }); + new Rule({ id, conditionsOp: 'and', conditions, actions }); const rules = [ rule( 'first', - [{ op: 'is', field: 'description', value: 'id1' }], - [{ op: 'set', field: 'name', value: 'sar' }], + [{ op: 'is', field: 'payee', value: 'id1' }], + [{ op: 'set', field: 'notes', value: 'sar' }], ), - rule('second', [ - { op: 'oneOf', field: 'description', value: ['id2', 'id3'] }, - ]), + rule('second', [{ op: 'oneOf', field: 'payee', value: ['id2', 'id3'] }]), rule( 'third', - [{ op: 'is', field: 'name', value: 'James' }], - [{ op: 'set', field: 'description', value: 'id3' }], + [{ op: 'is', field: 'notes', value: 'James' }], + [{ op: 'set', field: 'payee', value: 'id3' }], ), rule('fourth', [ - { op: 'is', field: 'name', value: 'James' }, + { op: 'is', field: 'notes', value: 'James' }, { op: 'gt', field: 'amount', value: 5 }, ]), rule('fifth', [ - { op: 'is', field: 'description2', value: 'id5' }, + { op: 'is', field: 'category', value: 'id5' }, { op: 'gt', field: 'amount', value: 5 }, { op: 'lt', field: 'amount', value: 10 }, ]), ]; const foundRules = []; - iterateIds(rules, 'description', rule => { + iterateIds(rules, 'payee', rule => { foundRules.push(rule.getId()); }); expect(foundRules).toEqual(['first', 'second', 'second', 'third']); @@ -526,30 +486,28 @@ describe('Rule', () => { describe('RuleIndexer', () => { test('indexing a single field works', () => { - const indexer = new RuleIndexer({ field: 'name' }); + const indexer = new RuleIndexer({ field: 'notes' }); const rule = new Rule({ conditionsOp: 'and', - conditions: [{ op: 'is', field: 'name', value: 'James' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + conditions: [{ op: 'is', field: 'notes', value: 'James' }], + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule); const rule2 = new Rule({ conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'foo' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule2); // rule2 always gets returned because it's not indexed and always // needs to be run - expect(indexer.getApplicableRules({ name: 'James' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'James' })).toEqual( new Set([rule, rule2]), ); - expect(indexer.getApplicableRules({ name: 'James2' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'James2' })).toEqual( new Set([rule2]), ); expect(indexer.getApplicableRules({ amount: 15 })).toEqual( @@ -563,27 +521,24 @@ describe('RuleIndexer', () => { const rule = new Rule({ conditionsOp: 'and', conditions: [ - { op: 'is', field: 'name', value: 'James' }, + { op: 'is', field: 'notes', value: 'James' }, { op: 'is', field: 'category', value: 'food' }, ], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule); const rule2 = new Rule({ conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'bars' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule2); const rule3 = new Rule({ conditionsOp: 'and', conditions: [{ op: 'is', field: 'date', value: '2020-01-20' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule3); @@ -593,18 +548,18 @@ describe('RuleIndexer', () => { expect(indexer.rules.get('*').size).toBe(1); expect( - indexer.getApplicableRules({ name: 'James', category: 'food' }), + indexer.getApplicableRules({ notes: 'James', category: 'food' }), ).toEqual(new Set([rule, rule3])); expect( - indexer.getApplicableRules({ name: 'James', category: 'f' }), + indexer.getApplicableRules({ notes: 'James', category: 'f' }), ).toEqual(new Set([rule, rule3])); expect( - indexer.getApplicableRules({ name: 'James', category: 'foo' }), + indexer.getApplicableRules({ notes: 'James', category: 'foo' }), ).toEqual(new Set([rule, rule3])); expect( - indexer.getApplicableRules({ name: 'James', category: 'bars' }), + indexer.getApplicableRules({ notes: 'James', category: 'bars' }), ).toEqual(new Set([rule2, rule3])); - expect(indexer.getApplicableRules({ name: 'James' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'James' })).toEqual( new Set([rule3]), ); }); @@ -616,8 +571,7 @@ describe('RuleIndexer', () => { id: 'id1', conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'food' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule); @@ -635,8 +589,7 @@ describe('RuleIndexer', () => { rule = new Rule({ conditionsOp: 'and', conditions: [{ op: 'is', field: 'category', value: 'alcohol' }], - actions: [{ op: 'set', field: 'name', value: 'Sarah' }], - fieldTypes, + actions: [{ op: 'set', field: 'notes', value: 'Sarah' }], }); indexer.index(rule); @@ -647,36 +600,34 @@ describe('RuleIndexer', () => { }); test('indexing works with the oneOf operator', () => { - const indexer = new RuleIndexer({ field: 'name', method: 'firstchar' }); + const indexer = new RuleIndexer({ field: 'notes', method: 'firstchar' }); const rule = new Rule({ conditionsOp: 'and', conditions: [ - { op: 'oneOf', field: 'name', value: ['James', 'Sarah', 'Evy'] }, + { op: 'oneOf', field: 'notes', value: ['James', 'Sarah', 'Evy'] }, ], actions: [{ op: 'set', field: 'category', value: 'Food' }], - fieldTypes, }); indexer.index(rule); const rule2 = new Rule({ conditionsOp: 'and', - conditions: [{ op: 'is', field: 'name', value: 'Georgia' }], + conditions: [{ op: 'is', field: 'notes', value: 'Georgia' }], actions: [{ op: 'set', field: 'category', value: 'Food' }], - fieldTypes, }); indexer.index(rule2); - expect(indexer.getApplicableRules({ name: 'James' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'James' })).toEqual( new Set([rule]), ); - expect(indexer.getApplicableRules({ name: 'Evy' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'Evy' })).toEqual( new Set([rule]), ); - expect(indexer.getApplicableRules({ name: 'Charlotte' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'Charlotte' })).toEqual( new Set([]), ); - expect(indexer.getApplicableRules({ name: 'Georgia' })).toEqual( + expect(indexer.getApplicableRules({ notes: 'Georgia' })).toEqual( new Set([rule2]), ); }); diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 8bf51d7ccfe..727e029f728 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -10,7 +10,12 @@ import { subDays, parseDate, } from '../../shared/months'; -import { sortNumbers, getApproxNumberThreshold } from '../../shared/rules'; +import { + sortNumbers, + getApproxNumberThreshold, + isValidOp, + FIELD_TYPES, +} from '../../shared/rules'; import { recurConfigToRSchedule } from '../../shared/schedules'; import { addSplitTransaction, @@ -169,6 +174,12 @@ const CONDITION_TYPES = { return value.filter(Boolean).map(val => val.toLowerCase()); } + assert( + typeof value === 'string', + 'not-string', + `Invalid string value (field: ${fieldName})`, + ); + if ( op === 'contains' || op === 'matches' || @@ -176,41 +187,7 @@ const CONDITION_TYPES = { op === 'hasTags' ) { assert( - typeof value === 'string' && value.length > 0, - 'no-empty-string', - `contains must have non-empty string (field: ${fieldName})`, - ); - } - - return value.toLowerCase(); - }, - }, - imported_payee: { - ops: [ - 'is', - 'contains', - 'matches', - 'oneOf', - 'isNot', - 'doesNotContain', - 'notOneOf', - ], - nullable: true, - parse(op, value, fieldName) { - if (op === 'oneOf' || op === 'notOneOf') { - assert( - Array.isArray(value), - 'no-empty-array', - `${op} must have an array value (field: ${fieldName}): ${JSON.stringify( - value, - )}`, - ); - return value.filter(Boolean).map(val => val.toLowerCase()); - } - - if (op === 'contains' || op === 'matches' || op === 'doesNotContain') { - assert( - typeof value === 'string' && value.length > 0, + value.length > 0, 'no-empty-string', `${op} must have non-empty string (field: ${fieldName})`, ); @@ -277,8 +254,8 @@ export class Condition { unparsedValue; value; - constructor(op, field, value, options, fieldTypes) { - const typeName = fieldTypes.get(field); + constructor(op, field, value, options) { + const typeName = FIELD_TYPES.get(field); assert(typeName, 'internal', 'Invalid condition field: ' + field); const type = CONDITION_TYPES[typeName]; @@ -291,7 +268,7 @@ export class Condition { `Invalid condition type: ${typeName} (field: ${field})`, ); assert( - type.ops.includes(op), + isValidOp(field, op), 'internal', `Invalid condition operator: ${op} (type: ${typeName}, field: ${field})`, ); @@ -514,7 +491,7 @@ export class Action { type; value; - constructor(op: ActionOperator, field, value, options, fieldTypes) { + constructor(op: ActionOperator, field, value, options) { assert( ACTION_OPS.includes(op), 'internal', @@ -522,7 +499,7 @@ export class Action { ); if (op === 'set') { - const typeName = fieldTypes.get(field); + const typeName = FIELD_TYPES.get(field); assert(typeName, 'internal', `Invalid field for action: ${field}`); this.field = field; this.type = typeName; @@ -738,23 +715,21 @@ export class Rule { conditionsOp, conditions, actions, - fieldTypes, }: { id?: string; stage?; conditionsOp; conditions; actions; - fieldTypes; }) { this.id = id; this.stage = stage; this.conditionsOp = conditionsOp; this.conditions = conditions.map( - c => new Condition(c.op, c.field, c.value, c.options, fieldTypes), + c => new Condition(c.op, c.field, c.value, c.options), ); this.actions = actions.map( - a => new Action(a.op, a.field, a.value, a.options, fieldTypes), + a => new Action(a.op, a.field, a.value, a.options), ); } diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index e22697ea407..20638526323 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -6,11 +6,7 @@ import { parseDate, dayFromDate, } from '../../shared/months'; -import { - FIELD_TYPES, - sortNumbers, - getApproxNumberThreshold, -} from '../../shared/rules'; +import { sortNumbers, getApproxNumberThreshold } from '../../shared/rules'; import { ungroupTransaction } from '../../shared/transactions'; import { partitionByField, fastSetMerge } from '../../shared/util'; import { @@ -157,10 +153,7 @@ export const ruleModel = { export function makeRule(data) { let rule; try { - rule = new Rule({ - ...ruleModel.toJS(data), - fieldTypes: FIELD_TYPES, - }); + rule = new Rule(ruleModel.toJS(data)); } catch (e) { console.warn('Invalid rule', e); if (e instanceof RuleError) { @@ -308,13 +301,7 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) { } try { - return new Condition( - cond.op, - cond.field, - cond.value, - cond.options, - FIELD_TYPES, - ); + return new Condition(cond.op, cond.field, cond.value, cond.options); } catch (e) { errors.push(e.type || 'internal'); console.log('conditionsToAQL: invalid condition: ' + e.message); @@ -524,20 +511,14 @@ export async function applyActions( try { if (action.op === 'set-split-amount') { - return new Action( - action.op, - null, - action.value, - action.options, - FIELD_TYPES, - ); + return new Action(action.op, null, action.value, action.options); } else if (action.op === 'link-schedule') { - return new Action(action.op, null, action.value, null, FIELD_TYPES); + return new Action(action.op, null, action.value, null); } else if ( action.op === 'prepend-notes' || action.op === 'append-notes' ) { - return new Action(action.op, null, action.value, null, FIELD_TYPES); + return new Action(action.op, null, action.value, null); } return new Action( @@ -545,7 +526,6 @@ export async function applyActions( action.field, action.value, action.options, - FIELD_TYPES, ); } catch (e) { console.log('Action error', e); @@ -665,7 +645,6 @@ export async function updatePayeeRenameRule(fromNames: string[], to: string) { conditionsOp: 'and', conditions: [{ op: 'oneOf', field: 'imported_payee', value: fromNames }], actions: [{ op: 'set', field: 'payee', value: to }], - fieldTypes: FIELD_TYPES, }); return insertRule(rule.serialize()); } @@ -774,7 +753,6 @@ export async function updateCategoryRules(transactions) { conditionsOp: 'and', conditions: [{ op: 'is', field: 'payee', value: payeeId }], actions: [{ op: 'set', field: 'category', value: category }], - fieldTypes: FIELD_TYPES, }); await insertRule(newRule.serialize()); } diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 9abfd5cd964..c510854948f 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -218,6 +218,8 @@ handlers['envelope-budget-month'] = async function ({ month }) { value(`sum-amount-${cat.id}`), value(`leftover-${cat.id}`), value(`carryover-${cat.id}`), + value(`goal-${cat.id}`), + value(`long-goal-${cat.id}`), ]); } } @@ -257,6 +259,8 @@ handlers['tracking-budget-month'] = async function ({ month }) { value(`budget-${cat.id}`), value(`sum-amount-${cat.id}`), value(`leftover-${cat.id}`), + value(`goal-${cat.id}`), + value(`long-goal-${cat.id}`), ]); if (!group.is_income) { diff --git a/packages/loot-core/src/server/rules/app.ts b/packages/loot-core/src/server/rules/app.ts index 95635fba766..df8b60def1d 100644 --- a/packages/loot-core/src/server/rules/app.ts +++ b/packages/loot-core/src/server/rules/app.ts @@ -1,5 +1,4 @@ // @ts-strict-ignore -import { FIELD_TYPES as ruleFieldTypes } from '../../shared/rules'; import { type RuleEntity } from '../../types/models'; import { Condition, Action, rankRules } from '../accounts/rules'; import * as rules from '../accounts/transaction-rules'; @@ -35,36 +34,17 @@ function validateRule(rule: Partial) { const conditionErrors = runValidation( rule.conditions, - cond => - new Condition( - cond.op, - cond.field, - cond.value, - cond.options, - ruleFieldTypes, - ), + cond => new Condition(cond.op, cond.field, cond.value, cond.options), ); const actionErrors = runValidation(rule.actions, action => action.op === 'set-split-amount' - ? new Action( - action.op, - null, - action.value, - action.options, - ruleFieldTypes, - ) + ? new Action(action.op, null, action.value, action.options) : action.op === 'link-schedule' - ? new Action(action.op, null, action.value, null, ruleFieldTypes) + ? new Action(action.op, null, action.value, null) : action.op === 'prepend-notes' || action.op === 'append-notes' - ? new Action(action.op, null, action.value, null, ruleFieldTypes) - : new Action( - action.op, - action.field, - action.value, - action.options, - ruleFieldTypes, - ), + ? new Action(action.op, null, action.value, null) + : new Action(action.op, action.field, action.value, action.options), ); if (conditionErrors || actionErrors) { diff --git a/packages/loot-core/src/server/schedules/app.ts b/packages/loot-core/src/server/schedules/app.ts index ea99443d4bc..9440ab4d071 100644 --- a/packages/loot-core/src/server/schedules/app.ts +++ b/packages/loot-core/src/server/schedules/app.ts @@ -73,13 +73,7 @@ export function getNextDate( ) { start = d.startOfDay(start); - const cond = new Condition( - dateCond.op, - 'date', - dateCond.value, - null, - new Map(Object.entries({ date: 'date' })), - ); + const cond = new Condition(dateCond.op, 'date', dateCond.value, null); const value = cond.getValue(); if (value.type === 'date') { diff --git a/packages/loot-core/src/shared/errors.ts b/packages/loot-core/src/shared/errors.ts index a6489b1e8be..ff8815c81ed 100644 --- a/packages/loot-core/src/shared/errors.ts +++ b/packages/loot-core/src/shared/errors.ts @@ -100,8 +100,9 @@ export class LazyLoadFailedError extends Error { type = 'app-init-failure'; meta = {}; - constructor(name: string) { + constructor(name: string, cause: unknown) { super(`Error: failed loading lazy-loaded module ${name}`); this.meta = { name }; + this.cause = cause; } } diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index 76eaa292605..aac09411aa4 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -1,9 +1,11 @@ // @ts-strict-ignore +import { FieldValueTypes, RuleConditionOp } from '../types/models'; + import { integerToAmount, amountToInteger, currencyToAmount } from './util'; // For now, this info is duplicated from the backend. Figure out how // to share it later. -export const TYPE_INFO = { +const TYPE_INFO = { date: { ops: ['is', 'isapprox', 'gt', 'gte', 'lt', 'lte'], nullable: false, @@ -38,18 +40,6 @@ export const TYPE_INFO = { ], nullable: true, }, - imported_payee: { - ops: [ - 'is', - 'contains', - 'matches', - 'oneOf', - 'isNot', - 'doesNotContain', - 'notOneOf', - ], - nullable: true, - }, number: { ops: ['is', 'isapprox', 'isbetween', 'gt', 'gte', 'lt', 'lte'], nullable: false, @@ -58,25 +48,58 @@ export const TYPE_INFO = { ops: ['is'], nullable: false, }, -}; +} as const; + +type FieldInfoConstraint = Record< + keyof FieldValueTypes, + { type: keyof typeof TYPE_INFO; disallowedOps?: Set } +>; + +const FIELD_INFO = { + imported_payee: { + type: 'string', + disallowedOps: new Set(['hasTags']), + }, + payee: { type: 'id' }, + date: { type: 'date' }, + notes: { type: 'string' }, + amount: { type: 'number' }, + category: { type: 'id' }, + account: { type: 'id' }, + cleared: { type: 'boolean' }, + reconciled: { type: 'boolean' }, + saved: { type: 'saved' }, +} as const satisfies FieldInfoConstraint; + +const fieldInfo: FieldInfoConstraint = FIELD_INFO; -export const FIELD_TYPES = new Map( - Object.entries({ - imported_payee: 'imported_payee', - payee: 'id', - date: 'date', - notes: 'string', - amount: 'number', - amountInflow: 'number', - amountOutfow: 'number', - category: 'id', - account: 'id', - cleared: 'boolean', - reconciled: 'boolean', - saved: 'saved', - }), +export const FIELD_TYPES = new Map( + Object.entries(FIELD_INFO).map(([field, info]) => [ + field as unknown as keyof FieldValueTypes, + info.type, + ]), ); +export function isValidOp(field: keyof FieldValueTypes, op: RuleConditionOp) { + const type = FIELD_TYPES.get(field); + if (!type) { + return false; + } + return ( + TYPE_INFO[type].ops.includes(op) && !fieldInfo[field].disallowedOps?.has(op) + ); +} + +export function getValidOps(field: keyof FieldValueTypes) { + const type = FIELD_TYPES.get(field); + if (!type) { + return []; + } + return TYPE_INFO[type].ops.filter( + op => !fieldInfo[field].disallowedOps?.has(op), + ); +} + export const ALLOCATION_METHODS = { 'fixed-amount': 'a fixed amount', 'fixed-percent': 'a fixed percent of the remainder', @@ -188,6 +211,10 @@ export function getFieldError(type) { case 'no-empty-array': case 'no-empty-string': return 'Value cannot be empty'; + case 'not-string': + return 'Value must be a string'; + case 'not-boolean': + return 'Value must be a boolean'; case 'not-number': return 'Value must be a number'; case 'invalid-field': diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 55fbda9c44c..3fcde847ff5 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -37,6 +37,8 @@ type FieldValueTypes = { payee: string; imported_payee: string; saved: string; + cleared: boolean; + reconciled: boolean; }; type BaseConditionEntity< diff --git a/upcoming-release-notes/3399.md b/upcoming-release-notes/3399.md new file mode 100644 index 00000000000..8f49154a008 --- /dev/null +++ b/upcoming-release-notes/3399.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [jfdoming] +--- + +Fix regression in case sensitivity for `is`/`matches` operator diff --git a/upcoming-release-notes/3514.md b/upcoming-release-notes/3514.md new file mode 100644 index 00000000000..23e3c759f5e --- /dev/null +++ b/upcoming-release-notes/3514.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [youngcw] +--- + +Add category goal info to the budget prewarm list for faster loading of indicator colors. diff --git a/upcoming-release-notes/3515.md b/upcoming-release-notes/3515.md new file mode 100644 index 00000000000..3200097e69e --- /dev/null +++ b/upcoming-release-notes/3515.md @@ -0,0 +1,6 @@ +--- +category: Bugfix +authors: [EtaoinWu] +--- + +Fix GoCardless linking (account selection window not appearing) diff --git a/upcoming-release-notes/3525.md b/upcoming-release-notes/3525.md new file mode 100644 index 00000000000..06481bc3e07 --- /dev/null +++ b/upcoming-release-notes/3525.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [qedi-r] +--- + +Expose underlying exception source when a module fails to load. diff --git a/upcoming-release-notes/3526.md b/upcoming-release-notes/3526.md new file mode 100644 index 00000000000..08c90d6f349 --- /dev/null +++ b/upcoming-release-notes/3526.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [matt-fidd] +--- + +Fix electron build workflow for ubuntu-latest