Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof createTestStore>, 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<typeof createTestStore>,
ids: string[]
) => {
const modal = (props: { ids: string[]; open: boolean }) => (
<Provider store={store}>
<BulkEditModal {...props} onClose={vi.fn()} onApply={vi.fn()} />
</Provider>
)
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')
})
})
17 changes: 10 additions & 7 deletions src/3-widgets/transaction/TransactionList/TopBar/BulkEditModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,23 +39,26 @@ export const BulkEditModal: FC<BulkEditModalProps> = ({
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()
Expand Down
72 changes: 72 additions & 0 deletions src/5-entities/transaction/thunks.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof dataReducer> }, 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<typeof createTestStore>) =>
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')
})
})
5 changes: 3 additions & 2 deletions src/5-entities/transaction/thunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down