From 14f29941b096793c94f2c65c98022b1ab2b46f6f Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Sat, 3 Aug 2024 15:24:01 +0100 Subject: [PATCH] :recycle: (typescript) make category and rule types stricter (#3180) --- .../components/budget/rollover/CoverMenu.tsx | 12 ++- .../budget/rollover/TransferMenu.tsx | 12 ++- .../src/components/filters/AppliedFilters.tsx | 5 +- .../components/filters/ConditionsOpMenu.tsx | 4 +- .../components/filters/FilterExpression.tsx | 23 ++--- .../components/filters/subfieldFromFilter.ts | 2 +- .../components/filters/updateFilterReducer.ts | 7 +- .../src/components/modals/CoverModal.tsx | 10 +- .../src/components/modals/TransferModal.tsx | 10 +- .../src/components/reports/ReportOptions.ts | 2 +- .../desktop-client/src/hooks/useFilters.ts | 23 ++--- packages/loot-core/src/mocks/budget.ts | 23 ++++- .../src/types/models/category-group.d.ts | 8 +- .../loot-core/src/types/models/category.d.ts | 2 +- .../loot-core/src/types/models/reports.d.ts | 4 +- packages/loot-core/src/types/models/rule.d.ts | 91 +++++++++++++++++-- upcoming-release-notes/3180.md | 6 ++ 17 files changed, 174 insertions(+), 70 deletions(-) create mode 100644 upcoming-release-notes/3180.md diff --git a/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx b/packages/desktop-client/src/components/budget/rollover/CoverMenu.tsx index 68c5d0c7f24..dd8de39fb34 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(null); function submit() { @@ -39,7 +41,7 @@ export function CoverMenu({ {node => ( 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 544504ab480..6d0dcf7aadb 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(null); @@ -59,7 +61,7 @@ export function TransferMenu({ 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 bb297777e30..be1c56f706e 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 baff4346741..82d9992a4e4 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: diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index 76cd1149b8f..55accb1c818 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 = { + 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({ field: originalField, customName, op, @@ -40,7 +37,7 @@ export function FilterExpression({ style, onChange, onDelete, -}: FilterExpressionProps) { +}: FilterExpressionProps) { 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 d4f60dfe202..c705cc375d3 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) { 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 694c0005922..5cb3727367e 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, + 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 fbf9e8f1410..acdc38cb513 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 31b58ebd587..105d361966e 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 6fe42586be1..e96b14c0f32 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 825b0e2cb3b..7fb50a1d746 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( ) { const [conditions, setConditions] = useState(initialConditions); const [conditionsOp, setConditionsOp] = useState<'and' | 'or'>('and'); - const [saved, setSaved] = useState(null); + const [saved, setSaved] = useState(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( [setConditions], ); - const onConditionsOpChange = useCallback( - condOp => { - setConditionsOp(condOp); - }, - [setConditionsOp], - ); - return useMemo( () => ({ conditions, @@ -60,7 +57,7 @@ export function useFilters( onApply, onUpdate, onDelete, - onConditionsOpChange, + onConditionsOpChange: setConditionsOp, }), [ conditions, @@ -68,7 +65,7 @@ export function useFilters( 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 82019aa0b46..cad37f5aaf8 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 = [ + const newCategoryGroups: Array = [ { name: 'Usual Expenses', categories: [ @@ -652,19 +653,31 @@ export async function createTestBudget(handlers: Handlers) { ], }, ]; + const categoryGroups: Array = []; 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 19dfeab5693..7e54bbe27be 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[]; +} + +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 2eb075de375..9e794fe6652 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 f336dbeb370..c8123137ba8 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 793d36dc256..a2e0fb3f16e 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]; 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 00000000000..1af6363525d --- /dev/null +++ b/upcoming-release-notes/3180.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +TypeScript: make category and rule entities stricter.