From 7779044f56b041c538af385ca651fe7691ee9893 Mon Sep 17 00:00:00 2001 From: Gleb <390857+GlebYavorski@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:53:05 +0300 Subject: [PATCH 1/4] fix(transaction): don't write "mixed" tag placeholder on bulk edit When bulk editing transactions with different categories, the modal puts a `mixed` placeholder in the tag list, and `modifyTags` is supposed to expand it into each transaction's own tags. For a transaction without any category `prevTags` is `null`, so the `id === 'mixed' && prevTags` check fell through to the `else` branch and stored the literal `mixed` string as a tag id. After that `usePopulatedTags()['mixed']` is `undefined` and rendering the transaction list crashes in `Symbol` (`tag.symbol` of undefined), taking the whole app down. Fixes #147 Co-Authored-By: Claude Opus 5 (1M context) --- src/5-entities/transaction/thunks.test.ts | 51 +++++++++++++++++++++++ src/5-entities/transaction/thunks.ts | 3 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 src/5-entities/transaction/thunks.test.ts diff --git a/src/5-entities/transaction/thunks.test.ts b/src/5-entities/transaction/thunks.test.ts new file mode 100644 index 000000000..8605243af --- /dev/null +++ b/src/5-entities/transaction/thunks.test.ts @@ -0,0 +1,51 @@ +import type { TTagId } from '6-shared/types' +import { describe, expect, test } from 'vitest' +import { store } from 'store' +import { applyClientPatch } from 'store/data' +import { makeTransaction } from './makeTransaction' +import { getTransactionsById } from './model' +import { bulkEditTransactions } from './thunks' + +function makeTr(id: string, tag: TTagId[] | null) { + return makeTransaction({ + id, + user: 0, + date: '2024-01-01', + incomeInstrument: 2, + incomeAccount: 'acc', + outcomeInstrument: 2, + outcomeAccount: 'acc', + outcome: 100, + tag, + }) +} + +describe('bulkEditTransactions', () => { + test('adds a tag keeping own tags of every transaction', () => { + store.dispatch( + applyClientPatch({ + transaction: [makeTr('tr1', ['tag1']), makeTr('tr2', ['tag2'])], + }) + ) + store.dispatch( + bulkEditTransactions(['tr1', 'tr2'], { tags: ['mixed', 'tag3'] }) + ) + const transactions = getTransactionsById(store.getState()) + expect(transactions.tr1.tag).toEqual(['tag1', 'tag3']) + expect(transactions.tr2.tag).toEqual(['tag2', 'tag3']) + }) + + test('never writes the "mixed" placeholder to a transaction without tags', () => { + store.dispatch( + applyClientPatch({ + transaction: [makeTr('tr3', ['tag1']), makeTr('tr4', null)], + }) + ) + store.dispatch( + bulkEditTransactions(['tr3', 'tr4'], { tags: ['mixed', 'tag3'] }) + ) + const transactions = getTransactionsById(store.getState()) + expect(transactions.tr3.tag).toEqual(['tag1', 'tag3']) + expect(transactions.tr4.tag).toEqual(['tag3']) + }) +}) diff --git a/src/5-entities/transaction/thunks.ts b/src/5-entities/transaction/thunks.ts index a7d34ef1a..fedecd8a0 100644 --- a/src/5-entities/transaction/thunks.ts +++ b/src/5-entities/transaction/thunks.ts @@ -138,7 +138,8 @@ const modifyTags = (prevTags: string[] | null, newTags?: string[]) => { const addId = (id: string) => result.includes(id) || id === 'null' ? '' : result.push(id) newTags?.forEach(id => { - if (id === 'mixed' && prevTags) prevTags.forEach(addId) + // 'mixed' is a placeholder for the transaction's own tags, never a real id + if (id === 'mixed') prevTags?.forEach(addId) else addId(id) }) return result From 2a2a23b597c97480cb9fd9ab819690cd15514264 Mon Sep 17 00:00:00 2001 From: Gleb <390857+GlebYavorski@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:01:29 +0300 Subject: [PATCH 2/4] refactor(tag): extract nullTagId and mixedTagId constants The `mixed` placeholder was a magic string duplicated across three layers (BulkEditModal, TagChip, transaction thunks) with no hint that it is not a real tag id. Same for the `null` tag id inside those places. Co-Authored-By: Claude Opus 5 (1M context) --- .../TransactionList/TopBar/BulkEditModal.tsx | 3 ++- src/5-entities/tag/model/constants.ts | 10 ++++++++++ src/5-entities/tag/model/index.ts | 1 + src/5-entities/tag/model/makeTag.ts | 3 ++- src/5-entities/tag/ui/TagChip.tsx | 4 ++-- src/5-entities/transaction/thunks.test.ts | 17 +++++++++++++++-- src/5-entities/transaction/thunks.ts | 7 ++++--- 7 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 src/5-entities/tag/model/constants.ts diff --git a/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx b/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx index 38bcb2682..dc809f348 100644 --- a/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx +++ b/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx @@ -15,6 +15,7 @@ import { } from '@mui/material' import { useAppDispatch } from 'store' import { trModel } from '5-entities/transaction' +import { mixedTagId } from '5-entities/tag' import { TagList } from '5-entities/tag/ui/TagList' type BulkEditModalProps = Modify void }> & { @@ -38,7 +39,7 @@ export const BulkEditModal: FC = ({ const sameComments = isSameComments(transactions) const types = getTypes(transactions) const tagType = types.income ? (types.outcome ? null : 'income') : 'outcome' - const commonTags = sameTags ? transactions[0]?.tag || [] : ['mixed'] + const commonTags = sameTags ? transactions[0]?.tag || [] : [mixedTagId] const [tags, setTags] = useState(commonTags) const [comment, setComment] = useState( diff --git a/src/5-entities/tag/model/constants.ts b/src/5-entities/tag/model/constants.ts new file mode 100644 index 000000000..cf8408ce1 --- /dev/null +++ b/src/5-entities/tag/model/constants.ts @@ -0,0 +1,10 @@ +import type { TTagId } from '6-shared/types' + +/** Id of the virtual tag for transactions without a category */ +export const nullTagId: TTagId = 'null' + +/** + * Placeholder used while editing several transactions at once. It stands for + * "keep the tags each transaction already has" and must never be saved. + */ +export const mixedTagId: TTagId = 'mixed' diff --git a/src/5-entities/tag/model/index.ts b/src/5-entities/tag/model/index.ts index 6b6b3ea1c..196423caf 100644 --- a/src/5-entities/tag/model/index.ts +++ b/src/5-entities/tag/model/index.ts @@ -3,6 +3,7 @@ import { getPopulatedTags, getTags, getTagsTree } from './model' import { makeTag } from './makeTag' import { createTag, patchTag } from './thunks' +export { mixedTagId, nullTagId } from './constants' export type { TagTreeNode } from './model' export type { TTagPopulated } from './populateTags' export type { TTagDraft } from './thunks' diff --git a/src/5-entities/tag/model/makeTag.ts b/src/5-entities/tag/model/makeTag.ts index 930e8923a..1b7f4aa4c 100644 --- a/src/5-entities/tag/model/makeTag.ts +++ b/src/5-entities/tag/model/makeTag.ts @@ -1,6 +1,7 @@ import { v1 as uuidv1 } from 'uuid' import { OptionalExceptFor, TTag } from '6-shared/types' import { t } from 'i18next' +import { nullTagId } from './constants' type TagDraft = OptionalExceptFor @@ -27,7 +28,7 @@ export const nullTag = makeTag({ // TODO: ??? i18n title: t('common:tagNull'), user: 0, - id: 'null', + id: nullTagId, budgetIncome: true, budgetOutcome: true, }) diff --git a/src/5-entities/tag/ui/TagChip.tsx b/src/5-entities/tag/ui/TagChip.tsx index c955bc75c..f2256a598 100644 --- a/src/5-entities/tag/ui/TagChip.tsx +++ b/src/5-entities/tag/ui/TagChip.tsx @@ -6,12 +6,12 @@ import { useTranslation } from 'react-i18next' import { Chip, ChipProps } from '@mui/material' import { CloseIcon } from '6-shared/ui/Icons' import { TagIcon } from '../../../6-shared/ui/TagIcon' -import { tagModel, TTagPopulated } from '../model' +import { mixedTagId, tagModel, TTagPopulated } from '../model' export const TagChip: FC = ({ id, ...rest }) => { const { t } = useTranslation() let tag = tagModel.usePopulatedTags()[id] - const label = id === 'mixed' ? t('mixedCategories') : getTagLabel(tag) + const label = id === mixedTagId ? t('mixedCategories') : getTagLabel(tag) return } label={label} {...rest} /> } diff --git a/src/5-entities/transaction/thunks.test.ts b/src/5-entities/transaction/thunks.test.ts index 8605243af..ecb049adf 100644 --- a/src/5-entities/transaction/thunks.test.ts +++ b/src/5-entities/transaction/thunks.test.ts @@ -2,6 +2,8 @@ import type { TTagId } from '6-shared/types' import { describe, expect, test } from 'vitest' import { store } from 'store' import { applyClientPatch } from 'store/data' +import { mixedTagId } from '5-entities/tag' +import { getMainTag } from './helpers' import { makeTransaction } from './makeTransaction' import { getTransactionsById } from './model' import { bulkEditTransactions } from './thunks' @@ -28,7 +30,7 @@ describe('bulkEditTransactions', () => { }) ) store.dispatch( - bulkEditTransactions(['tr1', 'tr2'], { tags: ['mixed', 'tag3'] }) + bulkEditTransactions(['tr1', 'tr2'], { tags: [mixedTagId, 'tag3'] }) ) const transactions = getTransactionsById(store.getState()) expect(transactions.tr1.tag).toEqual(['tag1', 'tag3']) @@ -42,10 +44,21 @@ describe('bulkEditTransactions', () => { }) ) store.dispatch( - bulkEditTransactions(['tr3', 'tr4'], { tags: ['mixed', 'tag3'] }) + bulkEditTransactions(['tr3', 'tr4'], { tags: [mixedTagId, 'tag3'] }) ) const transactions = getTransactionsById(store.getState()) expect(transactions.tr3.tag).toEqual(['tag1', 'tag3']) expect(transactions.tr4.tag).toEqual(['tag3']) }) }) + +describe('getMainTag', () => { + test('returns null for transactions without tags', () => { + expect(getMainTag(makeTr('tr5', null))).toBe(null) + expect(getMainTag(makeTr('tr6', []))).toBe(null) + }) + + test('returns the first tag', () => { + expect(getMainTag(makeTr('tr7', ['tag1', 'tag2']))).toBe('tag1') + }) +}) diff --git a/src/5-entities/transaction/thunks.ts b/src/5-entities/transaction/thunks.ts index fedecd8a0..99963e414 100644 --- a/src/5-entities/transaction/thunks.ts +++ b/src/5-entities/transaction/thunks.ts @@ -8,6 +8,7 @@ import { TTransactionId, } from '6-shared/types' import { applyClientPatch } from 'store/data' +import { mixedTagId, nullTagId } from '5-entities/tag' import { getTransactionsById } from './model' import { isViewed } from './helpers' @@ -136,10 +137,10 @@ const modifyTags = (prevTags: string[] | null, newTags?: string[]) => { if (!newTags) return prevTags let result: TTagId[] = [] const addId = (id: string) => - result.includes(id) || id === 'null' ? '' : result.push(id) + result.includes(id) || id === nullTagId ? '' : result.push(id) newTags?.forEach(id => { - // 'mixed' is a placeholder for the transaction's own tags, never a real id - if (id === 'mixed') prevTags?.forEach(addId) + // mixedTagId is a placeholder for the transaction's own tags, never a real id + if (id === mixedTagId) prevTags?.forEach(addId) else addId(id) }) return result From bd4e990c17171c4f8c454804222b4c1f48b20321 Mon Sep 17 00:00:00 2001 From: Gleb <390857+GlebYavorski@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:01:47 +0300 Subject: [PATCH 3/4] fix: survive unknown tag ids and empty tag arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lookups by tag id assumed the tag always exists, so a single unknown id (e.g. the `mixed` placeholder written by the bug in #147) crashed the whole transaction list and the year review. Fall back to the null tag instead. Transactions can also hold an empty tag array once the last category is removed in the transaction editor, which `tr.tag ? tr.tag[0] : …` treated as "has a category": `getMainTag` returned `undefined` instead of `null` and CSV export crashed on `t.tag[0].title`. Co-Authored-By: Claude Opus 5 (1M context) --- src/2-pages/Review/cards/OutcomeCard.tsx | 4 ++-- src/2-pages/Review/shared/getFacts.ts | 3 ++- .../TransactionList/Transaction/Transaction.Components.tsx | 7 ++++--- src/4-features/export/exportCSV/exportCSV.ts | 2 +- src/5-entities/transaction/helpers.ts | 3 +-- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/2-pages/Review/cards/OutcomeCard.tsx b/src/2-pages/Review/cards/OutcomeCard.tsx index d346cee0f..0d755683c 100644 --- a/src/2-pages/Review/cards/OutcomeCard.tsx +++ b/src/2-pages/Review/cards/OutcomeCard.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react' import { Box, IconButton, Stack, Typography } from '@mui/material' import { formatDate } from '6-shared/helpers/date' -import { tagModel } from '5-entities/tag' +import { nullTagId, tagModel } from '5-entities/tag' import { DisplayAmount } from '5-entities/currency/displayCurrency' import { Card, TCardProps } from '../shared/Card' import { useStats } from '../shared/getFacts' @@ -29,7 +29,7 @@ export function OutcomeCard(props: TCardProps) { const { val, tr } = topTransactions[i] const { date, comment, payee, tag } = tr - const tagTitle = tags[tag?.[0] || 'null'].title + const tagTitle = (tags[tag?.[0] || nullTagId] ?? tags[nullTagId])?.title let additionalInfo = [formatDate(date)] if (tagTitle) additionalInfo.push(tagTitle) if (payee) additionalInfo.push(payee) diff --git a/src/2-pages/Review/shared/getFacts.ts b/src/2-pages/Review/shared/getFacts.ts index 69a9cb842..8b8f739e1 100644 --- a/src/2-pages/Review/shared/getFacts.ts +++ b/src/2-pages/Review/shared/getFacts.ts @@ -14,6 +14,7 @@ import { instrumentModel } from '5-entities/currency/instrument' import { addFxAmount, convertFx } from '6-shared/helpers/money' import { accountModel } from '5-entities/account' import { merchantModel } from '5-entities/merchant' +import { nullTagId } from '5-entities/tag' import { TSelector, useAppSelector } from 'store/index' type TInfoNode = { @@ -72,7 +73,7 @@ export const getFactsYearly: TSelector> = createSelector( addToGroup(stats.byCurrency, codeMap[tr.outcomeInstrument], tr) } - addToGroup(stats.byTag, tr.tag ? tr.tag[0] : 'null', tr) + addToGroup(stats.byTag, tr.tag?.[0] ?? nullTagId, tr) addToGroup(stats.byMonth, parseDate(tr.date).getMonth().toString(), tr) addToGroup(stats.byWeekday, parseDate(tr.date).getDay().toString(), tr) } diff --git a/src/3-widgets/transaction/TransactionList/Transaction/Transaction.Components.tsx b/src/3-widgets/transaction/TransactionList/Transaction/Transaction.Components.tsx index 4eb8f1208..82049636b 100644 --- a/src/3-widgets/transaction/TransactionList/Transaction/Transaction.Components.tsx +++ b/src/3-widgets/transaction/TransactionList/Transaction/Transaction.Components.tsx @@ -8,7 +8,7 @@ import { Tooltip } from '6-shared/ui/Tooltip' import { useAppSelector } from 'store' import { accountModel } from '5-entities/account' import { TrType, trModel } from '5-entities/transaction' -import { TTagPopulated, tagModel } from '5-entities/tag' +import { TTagPopulated, nullTagId, tagModel } from '5-entities/tag' import { merchantModel } from '5-entities/merchant' import { SmartAmount } from '3-widgets/Amount' @@ -36,8 +36,9 @@ export const Symbol: FC = ({ ...rest }) => { const tags = tagModel.usePopulatedTags() - const mainTagId = tr.tag?.length ? tr.tag[0] : 'null' - const tag = tags[mainTagId] + const mainTagId = tr.tag?.length ? tr.tag[0] : nullTagId + // Fall back to the null tag: an unknown id would crash the whole list + const tag = tags[mainTagId] ?? tags[nullTagId] const { symbol, color } = getSymAndColor(trType, tag) return ( diff --git a/src/4-features/export/exportCSV/exportCSV.ts b/src/4-features/export/exportCSV/exportCSV.ts index b7c320dcf..d6c64c248 100644 --- a/src/4-features/export/exportCSV/exportCSV.ts +++ b/src/4-features/export/exportCSV/exportCSV.ts @@ -125,7 +125,7 @@ const transactionToRowObj = (t: PopulatedTransaction): RowObj => Дата: t.date, Создана: formatDate(t.created, 'yyyy-MM-dd HH:mm'), Тип: types[t.type as TrType], - Категория: t.tag ? t.tag[0].title : '', + Категория: t.tag?.[0]?.title || '', 'Доп категории': '', 'Со счёта': t.outcomeAccount ? t.outcomeAccount.title : '', Расход: !!t.outcome ? t.outcome : '', diff --git a/src/5-entities/transaction/helpers.ts b/src/5-entities/transaction/helpers.ts index 8771399ea..2e3d5f2ed 100644 --- a/src/5-entities/transaction/helpers.ts +++ b/src/5-entities/transaction/helpers.ts @@ -37,8 +37,7 @@ export function getTime(tr: TTransaction) { } export function getMainTag(tr: TTransaction) { - if (tr.tag) return tr.tag[0] - else return null + return tr.tag?.[0] ?? null } export function isViewed(tr: TTransaction) { From 8d7d5ab6b6999b94acc8cbf58352105745b959b0 Mon Sep 17 00:00:00 2001 From: Gleb <390857+GlebYavorski@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:01:47 +0300 Subject: [PATCH 4/4] test(tag): repair the i18next mock in TagSelect2 tests The mock had no default export, so importing `6-shared/localization` threw and the whole suite failed to load on master. Co-Authored-By: Claude Opus 5 (1M context) --- src/5-entities/tag/ui/TagSelect2.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/5-entities/tag/ui/TagSelect2.test.tsx b/src/5-entities/tag/ui/TagSelect2.test.tsx index 98b2df3ff..c95d4fa46 100644 --- a/src/5-entities/tag/ui/TagSelect2.test.tsx +++ b/src/5-entities/tag/ui/TagSelect2.test.tsx @@ -9,9 +9,18 @@ import dataReducer from 'store/data' import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { TagSelect2 } from './TagSelect2' -vi.mock('i18next', () => ({ - t: (key: string) => key, -})) +vi.mock('i18next', () => { + const t = (key: string) => key + const instance = { + t, + use: () => instance, + init: () => Promise.resolve(t), + on: () => {}, + language: 'en', + resolvedLanguage: 'en', + } + return { t, default: instance } +}) function makeOutcomeTag(id: string, title: string): TTag { return {