From 14f29941b096793c94f2c65c98022b1ab2b46f6f Mon Sep 17 00:00:00 2001
From: Matiss Janis Aboltins <matiss@mja.lv>
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 68c5d0c7f..dd8de39fb 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 544504ab4..6d0dcf7aa 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 bb297777e..be1c56f70 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 baff43467..82d9992a4 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 76cd1149b..55accb1c8 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 d4f60dfe2..c705cc375 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 694c00059..5cb372736 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 fbf9e8f14..acdc38cb5 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 31b58ebd5..105d36196 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 6fe42586b..e96b14c0f 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 825b0e2cb..7fb50a1d7 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 82019aa0b..cad37f5aa 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 19dfeab56..7e54bbe27 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 2eb075de3..9e794fe66 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 f336dbeb3..c8123137b 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 793d36dc2..a2e0fb3f1 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 000000000..1af636352
--- /dev/null
+++ b/upcoming-release-notes/3180.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [MatissJanis]
+---
+
+TypeScript: make category and rule entities stricter.
-- 
GitLab