diff --git a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx index 68c5d0c7f24299d5dae2520bd8628ada0338f2b8..dd8de39fb34c014ab67947175d04b9dab9d51773 100644 --- a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx @@ -19,10 +19,12 @@ export function CoverMenu({ onClose, }: CoverMenuProps) { const { grouped: originalCategoryGroups } = useCategories(); - let categoryGroups = originalCategoryGroups.filter(g => !g.is_income); - categoryGroups = showToBeBudgeted - ? addToBeBudgetedGroup(categoryGroups) - : categoryGroups; + const filteredCategoryGroups = originalCategoryGroups.filter( + g => !g.is_income, + ); + const categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(filteredCategoryGroups) + : filteredCategoryGroups; const [categoryId, setCategoryId] = useState<string | null>(null); function submit() { @@ -39,7 +41,7 @@ export function CoverMenu({ {node => ( <CategoryAutocomplete categoryGroups={categoryGroups} - value={categoryGroups.find(g => g.id === categoryId)} + value={categoryGroups.find(g => g.id === categoryId) ?? null} openOnFocus={true} onSelect={(id: string | undefined) => setCategoryId(id || null)} inputProps={{ diff --git a/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx b/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx index 544504ab480b4366fa1d38468a13e4a41009be4a..6d0dcf7aadbbc1548956366fd4ed0da2b1b07fea 100644 --- a/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx +++ b/packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx @@ -25,10 +25,12 @@ export function TransferMenu({ onClose, }: TransferMenuProps) { const { grouped: originalCategoryGroups } = useCategories(); - let categoryGroups = originalCategoryGroups.filter(g => !g.is_income); - if (showToBeBudgeted) { - categoryGroups = addToBeBudgetedGroup(categoryGroups); - } + const filteredCategoryGroups = originalCategoryGroups.filter( + g => !g.is_income, + ); + const categoryGroups = showToBeBudgeted + ? addToBeBudgetedGroup(filteredCategoryGroups) + : filteredCategoryGroups; const _initialAmount = integerToCurrency(Math.max(initialAmount, 0)); const [amount, setAmount] = useState<string | null>(null); @@ -59,7 +61,7 @@ export function TransferMenu({ <CategoryAutocomplete categoryGroups={categoryGroups} - value={categoryGroups.find(g => g.id === categoryId)} + value={categoryGroups.find(g => g.id === categoryId) ?? null} openOnFocus={true} onSelect={(id: string | undefined) => setCategoryId(id || null)} inputProps={{ diff --git a/packages/desktop-client/src/components/filters/AppliedFilters.tsx b/packages/desktop-client/src/components/filters/AppliedFilters.tsx index bb297777e305f3a7ab32a5ec3fb64d32ceefe92a..be1c56f706eb313c1c1b2ccfac65c90eeba5800a 100644 --- a/packages/desktop-client/src/components/filters/AppliedFilters.tsx +++ b/packages/desktop-client/src/components/filters/AppliedFilters.tsx @@ -15,10 +15,7 @@ type AppliedFiltersProps = { ) => void; onDelete: (filter: RuleConditionEntity) => void; conditionsOp: string; - onConditionsOpChange: ( - value: string, - conditions: RuleConditionEntity[], - ) => void; + onConditionsOpChange: (value: 'and' | 'or') => void; }; export function AppliedFilters({ diff --git a/packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx b/packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx index baff43467410c3f946a8265ac7770c80903d4c0d..82d9992a4e43538564d6f3022915155e3bfda264 100644 --- a/packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx +++ b/packages/desktop-client/src/components/filters/ConditionsOpMenu.tsx @@ -13,7 +13,7 @@ export function ConditionsOpMenu({ conditions, }: { conditionsOp: string; - onChange: (value: string, conditions: RuleConditionEntity[]) => void; + onChange: (value: 'and' | 'or') => void; conditions: RuleConditionEntity[]; }) { return conditions.length > 1 ? ( @@ -25,7 +25,7 @@ export function ConditionsOpMenu({ ['or', 'any'], ]} value={conditionsOp} - onChange={(name: string, value: string) => onChange(value, conditions)} + onChange={onChange} /> of: </Text> diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index 76cd1149b8fb333d0ee2b3b31700035e3676c3d6..55accb1c818545cf5a672bf1ba72c4bd509ee12d 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -2,10 +2,7 @@ import React, { useRef, useState } from 'react'; import { mapField, friendlyOp } from 'loot-core/src/shared/rules'; import { integerToCurrency } from 'loot-core/src/shared/util'; -import { - type RuleConditionOp, - type RuleConditionEntity, -} from 'loot-core/src/types/models'; +import { type RuleConditionEntity } from 'loot-core/src/types/models'; import { SvgDelete } from '../../icons/v0'; import { type CSSProperties, theme } from '../../style'; @@ -20,18 +17,18 @@ import { subfieldFromFilter } from './subfieldFromFilter'; let isDatepickerClick = false; -type FilterExpressionProps = { - field: string | undefined; - customName: string | undefined; - op: RuleConditionOp | undefined; - value: string | string[] | number | boolean | undefined; - options: RuleConditionEntity['options']; +type FilterExpressionProps<T extends RuleConditionEntity> = { + field: T['field']; + customName: T['customName']; + op: T['op']; + value: T['value']; + options: T['options']; style?: CSSProperties; - onChange: (cond: RuleConditionEntity) => void; + onChange: (cond: T) => void; onDelete: () => void; }; -export function FilterExpression({ +export function FilterExpression<T extends RuleConditionEntity>({ field: originalField, customName, op, @@ -40,7 +37,7 @@ export function FilterExpression({ style, onChange, onDelete, -}: FilterExpressionProps) { +}: FilterExpressionProps<T>) { const [editing, setEditing] = useState(false); const triggerRef = useRef(null); diff --git a/packages/desktop-client/src/components/filters/subfieldFromFilter.ts b/packages/desktop-client/src/components/filters/subfieldFromFilter.ts index d4f60dfe2028de349c06e53e1c1b4d9815d426f7..c705cc375d3cb70825322688dd8364500e16ba0c 100644 --- a/packages/desktop-client/src/components/filters/subfieldFromFilter.ts +++ b/packages/desktop-client/src/components/filters/subfieldFromFilter.ts @@ -4,7 +4,7 @@ export function subfieldFromFilter({ field, options, value, -}: RuleConditionEntity) { +}: Pick<RuleConditionEntity, 'field' | 'options' | 'value'>) { if (field === 'date') { if (typeof value === 'string') { if (value.length === 7) { diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 694c00059228f1ae57ac43c77dd50258db9a819c..5cb3727367e5e28702d4e73ada2747a474a51574 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -2,8 +2,11 @@ import { makeValue, FIELD_TYPES } from 'loot-core/src/shared/rules'; import { type RuleConditionEntity } from 'loot-core/src/types/models'; export function updateFilterReducer( - state: { field: string; value: string | string[] | number | boolean | null }, - action: RuleConditionEntity, + state: Pick<RuleConditionEntity, 'op' | 'field' | 'value'>, + action: { type: 'set-op' | 'set-value' } & Pick< + RuleConditionEntity, + 'op' | 'value' + >, ) { switch (action.type) { case 'set-op': { diff --git a/packages/desktop-client/src/components/modals/CoverModal.tsx b/packages/desktop-client/src/components/modals/CoverModal.tsx index fbf9e8f14104dd408d9cc616b4ae1eeb70430b94..acdc38cb51355c9e8f9d6833214cfbe6fd3b3c09 100644 --- a/packages/desktop-client/src/components/modals/CoverModal.tsx +++ b/packages/desktop-client/src/components/modals/CoverModal.tsx @@ -27,10 +27,12 @@ export function CoverModal({ }: CoverModalProps) { const { grouped: originalCategoryGroups } = useCategories(); const [categoryGroups, categories] = useMemo(() => { - let expenseGroups = originalCategoryGroups.filter(g => !g.is_income); - expenseGroups = showToBeBudgeted - ? addToBeBudgetedGroup(expenseGroups) - : expenseGroups; + const filteredCategoryGroups = originalCategoryGroups.filter( + g => !g.is_income, + ); + const expenseGroups = showToBeBudgeted + ? addToBeBudgetedGroup(filteredCategoryGroups) + : filteredCategoryGroups; const expenseCategories = expenseGroups.flatMap(g => g.categories || []); return [expenseGroups, expenseCategories]; }, [originalCategoryGroups, showToBeBudgeted]); diff --git a/packages/desktop-client/src/components/modals/TransferModal.tsx b/packages/desktop-client/src/components/modals/TransferModal.tsx index 31b58ebd587a87c2a36f47277ff2e7abcba76e32..105d361966ef833311b8a64a5ffc143927b442eb 100644 --- a/packages/desktop-client/src/components/modals/TransferModal.tsx +++ b/packages/desktop-client/src/components/modals/TransferModal.tsx @@ -30,10 +30,12 @@ export function TransferModal({ }: TransferModalProps) { const { grouped: originalCategoryGroups } = useCategories(); const [categoryGroups, categories] = useMemo(() => { - let expenseGroups = originalCategoryGroups.filter(g => !g.is_income); - expenseGroups = showToBeBudgeted - ? addToBeBudgetedGroup(expenseGroups) - : expenseGroups; + const filteredCategoryGroups = originalCategoryGroups.filter( + g => !g.is_income, + ); + const expenseGroups = showToBeBudgeted + ? addToBeBudgetedGroup(filteredCategoryGroups) + : filteredCategoryGroups; const expenseCategories = expenseGroups.flatMap(g => g.categories || []); return [expenseGroups, expenseCategories]; }, [originalCategoryGroups, showToBeBudgeted]); diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts index 6fe42586be10cbb16a5c5fa01a7028fb178cb233..e96b14c0f32f7e709899d0371c4b17b1e4db5375 100644 --- a/packages/desktop-client/src/components/reports/ReportOptions.ts +++ b/packages/desktop-client/src/components/reports/ReportOptions.ts @@ -286,7 +286,7 @@ type UncategorizedGroupEntity = Pick< const uncategorizedGroup: UncategorizedGroupEntity = { name: 'Uncategorized & Off Budget', - id: undefined, + id: 'uncategorized', hidden: false, categories: [uncategorizedCategory, transferCategory, offBudgetCategory], }; diff --git a/packages/desktop-client/src/hooks/useFilters.ts b/packages/desktop-client/src/hooks/useFilters.ts index 825b0e2cb3b2ecadf7c5f42ee9938e4b14e93cc9..7fb50a1d74678628e35cac722518cfdb6f1ee858 100644 --- a/packages/desktop-client/src/hooks/useFilters.ts +++ b/packages/desktop-client/src/hooks/useFilters.ts @@ -1,4 +1,3 @@ -// @ts-strict-ignore import { useCallback, useMemo, useState } from 'react'; import { type RuleConditionEntity } from 'loot-core/types/models/rule'; @@ -8,14 +7,19 @@ export function useFilters<T extends RuleConditionEntity>( ) { const [conditions, setConditions] = useState<T[]>(initialConditions); const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>('and'); - const [saved, setSaved] = useState<T[]>(null); + const [saved, setSaved] = useState<T[] | null>(null); const onApply = useCallback( - conditionsOrSavedFilter => { + ( + conditionsOrSavedFilter: + | null + | { conditions: T[]; conditionsOp: 'and' | 'or'; id: T[] | null } + | T, + ) => { if (conditionsOrSavedFilter === null) { setConditions([]); setSaved(null); - } else if (conditionsOrSavedFilter.conditions) { + } else if ('conditions' in conditionsOrSavedFilter) { setConditions([...conditionsOrSavedFilter.conditions]); setConditionsOp(conditionsOrSavedFilter.conditionsOp); setSaved(conditionsOrSavedFilter.id); @@ -45,13 +49,6 @@ export function useFilters<T extends RuleConditionEntity>( [setConditions], ); - const onConditionsOpChange = useCallback( - condOp => { - setConditionsOp(condOp); - }, - [setConditionsOp], - ); - return useMemo( () => ({ conditions, @@ -60,7 +57,7 @@ export function useFilters<T extends RuleConditionEntity>( onApply, onUpdate, onDelete, - onConditionsOpChange, + onConditionsOpChange: setConditionsOp, }), [ conditions, @@ -68,7 +65,7 @@ export function useFilters<T extends RuleConditionEntity>( onApply, onUpdate, onDelete, - onConditionsOpChange, + setConditionsOp, conditionsOp, ], ); diff --git a/packages/loot-core/src/mocks/budget.ts b/packages/loot-core/src/mocks/budget.ts index 82019aa0b4629565a233a53d9d7c2b3dfdad4211..cad37f5aaf803942c49ccd4876c1aa2036bd0bbd 100644 --- a/packages/loot-core/src/mocks/budget.ts +++ b/packages/loot-core/src/mocks/budget.ts @@ -12,6 +12,7 @@ import { q } from '../shared/query'; import type { Handlers } from '../types/handlers'; import type { CategoryGroupEntity, + NewCategoryGroupEntity, NewPayeeEntity, NewTransactionEntity, } from '../types/models'; @@ -618,7 +619,7 @@ export async function createTestBudget(handlers: Handlers) { }), ); - const categoryGroups: Array<CategoryGroupEntity> = [ + const newCategoryGroups: Array<NewCategoryGroupEntity> = [ { name: 'Usual Expenses', categories: [ @@ -652,19 +653,31 @@ export async function createTestBudget(handlers: Handlers) { ], }, ]; + const categoryGroups: Array<CategoryGroupEntity> = []; await runMutator(async () => { - for (const group of categoryGroups) { - group.id = await handlers['category-group-create']({ + for (const group of newCategoryGroups) { + const groupId = await handlers['category-group-create']({ name: group.name, isIncome: group.is_income, }); + categoryGroups.push({ + ...group, + id: groupId, + categories: [], + }); + for (const category of group.categories) { - category.id = await handlers['category-create']({ + const categoryId = await handlers['category-create']({ ...category, isIncome: category.is_income ? 1 : 0, - groupId: group.id, + groupId, + }); + + categoryGroups[categoryGroups.length - 1].categories.push({ + ...category, + id: categoryId, }); } } diff --git a/packages/loot-core/src/types/models/category-group.d.ts b/packages/loot-core/src/types/models/category-group.d.ts index 19dfeab5693b726d7f4a4ee6ef3d423660fe6bbf..7e54bbe27be401d5af0aeba7e7ed2f88b1481924 100644 --- a/packages/loot-core/src/types/models/category-group.d.ts +++ b/packages/loot-core/src/types/models/category-group.d.ts @@ -1,11 +1,15 @@ import { CategoryEntity } from './category'; -export interface CategoryGroupEntity { - id?: string; +export interface NewCategoryGroupEntity { name: string; is_income?: boolean; sort_order?: number; tombstone?: boolean; hidden?: boolean; + categories?: Omit<CategoryEntity, 'id'>[]; +} + +export interface CategoryGroupEntity extends NewCategoryGroupEntity { + id: string; categories?: CategoryEntity[]; } diff --git a/packages/loot-core/src/types/models/category.d.ts b/packages/loot-core/src/types/models/category.d.ts index 2eb075de375f22b30bb6f7e160042bacb3e82d4c..9e794fe665248141d7b8e2a45cf1b78d66a13389 100644 --- a/packages/loot-core/src/types/models/category.d.ts +++ b/packages/loot-core/src/types/models/category.d.ts @@ -1,5 +1,5 @@ export interface CategoryEntity { - id?: string; + id: string; name: string; is_income?: boolean; cat_group?: string; diff --git a/packages/loot-core/src/types/models/reports.d.ts b/packages/loot-core/src/types/models/reports.d.ts index f336dbeb3702a4044a7c5984b11e935bf3d3e163..c8123137ba8796e26e864f2d14067ee44cf97de8 100644 --- a/packages/loot-core/src/types/models/reports.d.ts +++ b/packages/loot-core/src/types/models/reports.d.ts @@ -20,7 +20,7 @@ export interface CustomReportEntity { selectedCategories?: CategoryEntity[]; graphType: string; conditions?: RuleConditionEntity[]; - conditionsOp: string; + conditionsOp: 'and' | 'or'; data?: GroupedEntity; tombstone?: boolean; } @@ -143,7 +143,7 @@ export interface CustomReportData { selected_categories?: CategoryEntity[]; graph_type: string; conditions?: RuleConditionEntity[]; - conditions_op: string; + conditions_op: 'and' | 'or'; metadata?: GroupedEntity; interval: string; color_scheme?: string; diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 793d36dc2561151e06c502c0232ee857a6aecb4b..a2e0fb3f16eb1a9a2ae7f2a7b06601eab74a0c85 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -27,10 +27,26 @@ export type RuleConditionOp = | 'doesNotContain' | 'matches'; -export interface RuleConditionEntity { - field?: string; - op?: RuleConditionOp; - value?: string | string[] | number | boolean; +type FieldValueTypes = { + account: string; + amount: number; + category: string; + date: string; + notes: string; + payee: string; + imported_payee: string; + saved: string; +}; + +type BaseConditionEntity< + Field extends keyof FieldValueTypes, + Op extends RuleConditionOp, +> = { + field: Field; + op: Op; + value: Op extends 'oneOf' | 'notOneOf' + ? Array<FieldValueTypes[Field]> + : FieldValueTypes[Field]; options?: { inflow?: boolean; outflow?: boolean; @@ -38,9 +54,72 @@ export interface RuleConditionEntity { year?: boolean; }; conditionsOp?: string; - type?: string; + type?: 'id' | 'boolean' | 'date' | 'number'; customName?: string; -} +}; + +export type RuleConditionEntity = + | BaseConditionEntity< + 'account', + | 'is' + | 'isNot' + | 'oneOf' + | 'notOneOf' + | 'contains' + | 'doesNotContain' + | 'matches' + > + | BaseConditionEntity< + 'category', + | 'is' + | 'isNot' + | 'oneOf' + | 'notOneOf' + | 'contains' + | 'doesNotContain' + | 'matches' + > + | BaseConditionEntity< + 'amount', + 'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte' + > + | BaseConditionEntity< + 'date', + 'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte' + > + | BaseConditionEntity< + 'notes', + | 'is' + | 'isNot' + | 'oneOf' + | 'notOneOf' + | 'contains' + | 'doesNotContain' + | 'matches' + > + | BaseConditionEntity< + 'payee', + | 'is' + | 'isNot' + | 'oneOf' + | 'notOneOf' + | 'contains' + | 'doesNotContain' + | 'matches' + > + | BaseConditionEntity< + 'imported_payee', + | 'is' + | 'isNot' + | 'oneOf' + | 'notOneOf' + | 'contains' + | 'doesNotContain' + | 'matches' + > + | BaseConditionEntity<'saved', 'is'> + | BaseConditionEntity<'cleared', 'is'> + | BaseConditionEntity<'reconciled', 'is'>; export type RuleActionEntity = | SetRuleActionEntity diff --git a/upcoming-release-notes/3180.md b/upcoming-release-notes/3180.md new file mode 100644 index 0000000000000000000000000000000000000000..1af6363525d0a5f39e1ae3ec3cfa5a844e587aa5 --- /dev/null +++ b/upcoming-release-notes/3180.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +TypeScript: make category and rule entities stricter.