From cab19e72763a49d159d64ba88a936a893dbf59dd Mon Sep 17 00:00:00 2001 From: Gleb <390857+GlebYavorski@users.noreply.github.com> Date: Thu, 30 Jul 2026 16:05:12 +0300 Subject: [PATCH] Allow clearing transaction comment in bulk edit (#98) Erasing the comment in the bulk edit dialog had no effect: an empty string was treated as "no change" in two places, so the comment stayed on the transaction. - `modifyComment` now only keeps the previous comment when the new one is `undefined`; an empty string clears it (stored as `null`). - `BulkEditModal` no longer skips the dispatch for an empty comment. It sends the comment only when it differs from the common one, mirroring how tags already work, so untouched fields never overwrite anything. - The comment field is also reset when the dialog opens. Before, it kept the value from the previous session and never showed the comment of the selected transactions. Co-Authored-By: Claude Opus 5 (1M context) --- .../TopBar/BulkEditModal.test.tsx | 102 ++++++++++++++++++ .../TransactionList/TopBar/BulkEditModal.tsx | 17 +-- src/5-entities/transaction/thunks.test.ts | 72 +++++++++++++ src/5-entities/transaction/thunks.ts | 5 +- 4 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.test.tsx create mode 100644 src/5-entities/transaction/thunks.test.ts diff --git a/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.test.tsx b/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.test.tsx new file mode 100644 index 000000000..01ef1102b --- /dev/null +++ b/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.test.tsx @@ -0,0 +1,102 @@ +import '@testing-library/jest-dom' +import type { TTransaction } from '6-shared/types' +import { configureStore } from '@reduxjs/toolkit' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import { Provider } from 'react-redux' +import i18n from 'i18next' +import { beforeAll, describe, expect, test, vi } from 'vitest' +import dataReducer from 'store/data' +import { trModel } from '5-entities/transaction' +import { BulkEditModal } from './BulkEditModal' + +beforeAll(async () => { + await i18n.changeLanguage('en') +}) + +const makeTr = (id: string, comment: string | null): TTransaction => + trModel.makeTransaction({ + id, + user: 0, + date: '2022-10-20', + incomeInstrument: 2, + incomeAccount: 'acc1', + outcomeInstrument: 2, + outcomeAccount: 'acc1', + outcome: 100, + comment, + }) + +const createTestStore = (transactions: TTransaction[]) => + configureStore({ + reducer: { data: dataReducer }, + preloadedState: { + data: { + current: { + serverTimestamp: 0, + instrument: {}, + country: {}, + company: {}, + user: {}, + merchant: {}, + account: {}, + tag: {}, + budget: {}, + reminder: {}, + reminderMarker: {}, + transaction: Object.fromEntries(transactions.map(tr => [tr.id, tr])), + }, + }, + }, + }) + +const getComment = (store: ReturnType, id: string) => + store.getState().data.current.transaction[id].comment + +const commentField = () => screen.getByRole('textbox') +const saveButton = () => screen.getByRole('button', { name: 'Apply Changes' }) + +/** + * The modal lives permanently in the tree and gets ids only when the user + * selects transactions, so tests open it the same way the app does. + */ +const renderAndOpen = ( + store: ReturnType, + ids: string[] +) => { + const modal = (props: { ids: string[]; open: boolean }) => ( + + + + ) + const { rerender } = render(modal({ ids: [], open: false })) + rerender(modal({ ids, open: true })) +} + +describe('BulkEditModal', () => { + test('shows the current comment of the selected transaction', () => { + const store = createTestStore([makeTr('tr1', 'Old comment')]) + renderAndOpen(store, ['tr1']) + expect(commentField()).toHaveValue('Old comment') + }) + + test('clears the comment when the field is emptied', async () => { + const store = createTestStore([makeTr('tr1', 'Old comment')]) + renderAndOpen(store, ['tr1']) + await userEvent.clear(commentField()) + await userEvent.click(saveButton()) + expect(getComment(store, 'tr1')).toBe(null) + }) + + test('does not touch comments when the field is left untouched', async () => { + const store = createTestStore([ + makeTr('tr1', 'One'), + makeTr('tr2', 'Another'), + ]) + renderAndOpen(store, ['tr1', 'tr2']) + await userEvent.click(saveButton()) + expect(getComment(store, 'tr1')).toBe('One') + expect(getComment(store, 'tr2')).toBe('Another') + }) +}) diff --git a/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx b/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx index 38bcb2682..755dd540e 100644 --- a/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx +++ b/src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx @@ -39,23 +39,26 @@ export const BulkEditModal: FC = ({ const types = getTypes(transactions) const tagType = types.income ? (types.outcome ? null : 'income') : 'outcome' const commonTags = sameTags ? transactions[0]?.tag || [] : ['mixed'] + const commonComment = sameComments ? transactions[0]?.comment || '' : '' const [tags, setTags] = useState(commonTags) - const [comment, setComment] = useState( - sameComments ? transactions[0]?.comment || '' : '' - ) + const [comment, setComment] = useState(commonComment) useEffect(() => { - if (open) setTags(commonTags) + if (open) { + setTags(commonTags) + setComment(commonComment) + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setTags, ids, open]) + }, [setTags, setComment, ids, open]) const onSave = () => { const opts = { tags: equalArrays(commonTags, tags) ? undefined : tags, - comment, + // An empty comment is a valid change, so untouched means `undefined` + comment: comment === commonComment ? undefined : comment, } - if (opts.tags || opts.comment) { + if (opts.tags || opts.comment !== undefined) { dispatch(trModel.bulkEditTransactions(ids, opts)) } onApply() diff --git a/src/5-entities/transaction/thunks.test.ts b/src/5-entities/transaction/thunks.test.ts new file mode 100644 index 000000000..684a69495 --- /dev/null +++ b/src/5-entities/transaction/thunks.test.ts @@ -0,0 +1,72 @@ +import type { TTransaction } from '6-shared/types' +import { configureStore } from '@reduxjs/toolkit' +import { describe, expect, test } from 'vitest' +import dataReducer from 'store/data' +import { makeTransaction } from './makeTransaction' +import { bulkEditTransactions } from './thunks' + +const makeTr = (comment: string | null): TTransaction => + makeTransaction({ + id: 'tr1', + user: 0, + date: '2022-10-20', + incomeInstrument: 2, + incomeAccount: 'acc1', + outcomeInstrument: 2, + outcomeAccount: 'acc1', + outcome: 100, + tag: ['tag1'], + comment, + }) + +const createTestStore = (tr: TTransaction) => + configureStore<{ data: ReturnType }, any>({ + reducer: { data: dataReducer }, + preloadedState: { + data: { + current: { + serverTimestamp: 0, + instrument: {}, + country: {}, + company: {}, + user: {}, + merchant: {}, + account: {}, + tag: {}, + budget: {}, + reminder: {}, + reminderMarker: {}, + transaction: { [tr.id]: tr }, + }, + }, + }, + }) + +const getComment = (store: ReturnType) => + store.getState().data.current.transaction.tr1.comment + +describe('bulkEditTransactions', () => { + test('sets a comment', () => { + const store = createTestStore(makeTr(null)) + store.dispatch(bulkEditTransactions(['tr1'], { comment: 'New comment' })) + expect(getComment(store)).toBe('New comment') + }) + + test('clears a comment when an empty string is passed', () => { + const store = createTestStore(makeTr('Old comment')) + store.dispatch(bulkEditTransactions(['tr1'], { comment: '' })) + expect(getComment(store)).toBe(null) + }) + + test('keeps the comment when it is not passed', () => { + const store = createTestStore(makeTr('Old comment')) + store.dispatch(bulkEditTransactions(['tr1'], { tags: ['tag2'] })) + expect(getComment(store)).toBe('Old comment') + }) + + test('supports the $& placeholder for the previous comment', () => { + const store = createTestStore(makeTr('Old comment')) + store.dispatch(bulkEditTransactions(['tr1'], { comment: '$& and more' })) + expect(getComment(store)).toBe('Old comment and more') + }) +}) diff --git a/src/5-entities/transaction/thunks.ts b/src/5-entities/transaction/thunks.ts index a7d34ef1a..e8fb9ea7e 100644 --- a/src/5-entities/transaction/thunks.ts +++ b/src/5-entities/transaction/thunks.ts @@ -144,8 +144,9 @@ const modifyTags = (prevTags: string[] | null, newTags?: string[]) => { return result } const modifyComment = (prevComment: string | null, newComment?: string) => { - if (!newComment) return prevComment - return newComment.replaceAll('$&', prevComment || '') + // Only an omitted comment means "keep the old one". An empty string clears it. + if (newComment === undefined) return prevComment + return newComment.replaceAll('$&', prevComment || '') || null } function split(raw: TTransaction) {