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) {