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/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/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/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/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 { 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) { diff --git a/src/5-entities/transaction/thunks.test.ts b/src/5-entities/transaction/thunks.test.ts new file mode 100644 index 000000000..ecb049adf --- /dev/null +++ b/src/5-entities/transaction/thunks.test.ts @@ -0,0 +1,64 @@ +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' + +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: [mixedTagId, '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: [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 a7d34ef1a..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,9 +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 => { - if (id === 'mixed' && prevTags) 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