diff --git a/contentcuration/contentcuration/frontend/channelEdit/views/trash/__tests__/trashModal.spec.js b/contentcuration/contentcuration/frontend/channelEdit/views/trash/__tests__/trashModal.spec.js index 80225b2027..a3d5e8bf8d 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/views/trash/__tests__/trashModal.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/views/trash/__tests__/trashModal.spec.js @@ -1,171 +1,259 @@ -import { mount } from '@vue/test-utils'; -import TrashModal from '../TrashModal'; +import { render, screen, waitFor, configure } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; + import { factory } from '../../../store'; -import router from '../../../router'; import { RouteNames } from '../../../constants'; +import TrashModal from '../TrashModal'; const store = factory(); +const TRASH_ID = 'trash-root-id'; + const testChildren = [ - { - id: 'test1', - title: 'Item', - kind: 'video', - modified: new Date(2020, 1, 20), - }, - { - id: 'test2', - title: 'Item', - kind: 'audio', - modified: new Date(2020, 2, 1), - }, - { - id: 'test3', - title: 'Topic', - kind: 'topic', - modified: new Date(2020, 1, 1), - }, + { id: 'test1', title: 'Item', kind: 'video', modified: new Date(2020, 1, 20) }, + { id: 'test2', title: 'Item', kind: 'audio', modified: new Date(2020, 2, 1) }, + { id: 'test3', title: 'Topic', kind: 'topic', modified: new Date(2020, 1, 1) }, ]; -function makeWrapper(items) { - const loadContentNodes = jest.spyOn(TrashModal.methods, 'loadContentNodes'); - loadContentNodes.mockImplementation(() => Promise.resolve()); - const loadAncestors = jest.spyOn(TrashModal.methods, 'loadAncestors'); - loadAncestors.mockImplementation(() => Promise.resolve()); - const loadChildren = jest.spyOn(TrashModal.methods, 'loadChildren'); - loadChildren.mockImplementation(() => Promise.resolve({ more: null, results: [] })); - - const wrapper = mount(TrashModal, { - store, - router, - computed: { - currentChannel() { - return { - id: 'current channel', - }; - }, - trashId() { - return 'trash'; - }, - items() { - return items || testChildren; +async function makeWrapper(items = testChildren, isLoading = false) { + const loadContentNodesSpy = jest + .spyOn(TrashModal.methods, 'loadContentNodes') + .mockResolvedValue({}); + jest.spyOn(TrashModal.methods, 'loadAncestors').mockResolvedValue(); + jest.spyOn(TrashModal.methods, 'removeContentNodes').mockResolvedValue(); + const loadNodesSpy = jest.spyOn(TrashModal.methods, 'loadNodes'); + + if (isLoading) { + jest.spyOn(TrashModal.methods, 'loadChildren').mockReturnValue(new Promise(() => {})); + } else { + jest.spyOn(TrashModal.methods, 'loadChildren').mockResolvedValue({ + more: items === testChildren ? null : { parent: TRASH_ID, page: 2 }, + results: [], + }); + } + + const router = new VueRouter({ + routes: [ + { name: RouteNames.TRASH, path: '/:nodeId/trash', component: TrashModal }, + { + name: RouteNames.TREE_VIEW, + path: '/:nodeId/:detailNodeId?', + component: { template: '
Tree
' }, }, - offline() { - return false; + ], + }); + + router.replace({ name: RouteNames.TRASH, params: { nodeId: 'test' } }).catch(() => {}); + + const routerPush = jest.spyOn(router, 'push').mockResolvedValue(); + + const utils = render( + TrashModal, + { + store, + router, + stubs: { + ResourceDrawer: true, + OfflineText: true, }, - backLink() { - return { - name: 'TEST_PARENT', - }; + computed: { + currentChannel: () => ({ id: 'test-channel-id' }), + trashId: () => TRASH_ID, + items: () => items, + offline: () => false, + backLink: () => ({ name: RouteNames.TREE_VIEW, params: { nodeId: 'test' } }), + getSelectedTopicAndResourceCountText: () => ids => `${ids.length} items selected`, + counts: () => ({ topicCount: 0, resourceCount: 0 }), }, }, - stubs: { - ResourceDrawer: true, - OfflineText: true, + localVue => { + localVue.use(VueRouter); }, - }); + ); - return [wrapper, { loadContentNodes, loadAncestors, loadChildren }]; + if (!isLoading) { + await waitFor(() => { + expect(screen.queryByTestId('loading')).not.toBeInTheDocument(); + }); + } + + const user = userEvent.setup(); + return { ...utils, routerPush, user, loadNodesSpy, loadContentNodesSpy }; } -describe('trashModal', () => { - let wrapper; +describe('TrashModal', () => { + beforeAll(() => configure({ testIdAttribute: 'data-test' })); + afterAll(() => configure({ testIdAttribute: 'data-testid' })); - beforeEach(async () => { - [wrapper] = makeWrapper(); - router.replace({ name: RouteNames.TRASH, params: { nodeId: 'test' } }).catch(() => {}); - await wrapper.setData({ loading: false }); + beforeEach(() => { + jest.restoreAllMocks(); }); describe('on load', () => { - it('should show loading indicator if content is loading', async () => { - await wrapper.setData({ loading: true }); - expect(wrapper.findComponent('[data-test="loading"]').exists()).toBe(true); + it('shows a loading indicator while content is loading', async () => { + await makeWrapper(testChildren, true); + expect(screen.getByTestId('loading')).toBeInTheDocument(); }); - it('should show empty text if there are no items', async () => { - const [emptyWrapper] = makeWrapper([]); - await emptyWrapper.setData({ loading: false }); - expect(emptyWrapper.findComponent('[data-test="empty"]').exists()).toBe(true); + it('shows empty text when trash has no items', async () => { + await makeWrapper([]); + expect(screen.getByTestId('empty')).toBeInTheDocument(); }); - it('should show items in list', () => { - expect(wrapper.findComponent('[data-test="list"]').exists()).toBe(true); + it('shows the item list when trash has items', async () => { + await makeWrapper(); + expect(screen.getByTestId('list')).toBeInTheDocument(); }); }); - describe('on topic tree selection', () => { - it('clicking item should set previewNodeId', async () => { - await wrapper.findComponent('[data-test="item"]').trigger('click'); - expect(wrapper.vm.previewNodeId).toBe(testChildren[0].id); - }); + describe('on item selection', () => { + it('checking an item enables the Delete and Restore buttons', async () => { + const { user } = await makeWrapper(); - it('checking item in list should add the item ID to the selected array', () => { - wrapper - .findComponent('[data-test="checkbox"]') - .find('input[type="checkbox"]') - .element.click(); - expect(wrapper.vm.selected).toEqual(['test1']); + expect(screen.getByTestId('delete')).toBeDisabled(); + expect(screen.getByTestId('restore')).toBeDisabled(); + + await user.click(screen.getAllByRole('checkbox')[1]); + + await waitFor(() => { + expect(screen.getByTestId('delete')).toBeEnabled(); + expect(screen.getByTestId('restore')).toBeEnabled(); + }); }); - it('checking select all checkbox should check all items', () => { - wrapper.findComponent('[data-test="selectall"]').vm.$emit('input', true); - expect(wrapper.vm.selected).toEqual(testChildren.map(c => c.id)); + it('checking the select-all checkbox checks all items', async () => { + const { user } = await makeWrapper(); + await user.click(screen.getByTestId('selectall')); + + await waitFor(() => { + screen + .getAllByRole('checkbox') + .slice(1) + .forEach(cb => { + expect(cb).toBeChecked(); + }); + }); }); }); describe('on close', () => { - it('clicking close button should go back to parent route', async () => { - await wrapper.findComponent('[data-test="close"]').trigger('click'); - expect(wrapper.vm.$route.name).toBe('TEST_PARENT'); + it('clicking the close button navigates back to the tree view', async () => { + const { routerPush, user } = await makeWrapper(); + + const closeButton = await screen.findByTestId('close'); + await user.click(closeButton); + + await waitFor(() => { + expect(routerPush).toHaveBeenCalledWith( + expect.objectContaining({ name: RouteNames.TREE_VIEW }), + ); + }); }); }); describe('on delete', () => { - it('DELETE button should be disabled if no items are selected', () => { - expect(wrapper.findComponent('[data-test="delete"]').vm.disabled).toBe(true); + it('Delete button is disabled when no items are selected', async () => { + await makeWrapper(); + expect(screen.getByTestId('delete')).toBeDisabled(); }); - it('clicking DELETE button should open delete confirmation dialog', async () => { - await wrapper.setData({ selected: testChildren.map(c => c.id) }); - await wrapper.findComponent('[data-test="delete"]').trigger('click'); - expect(wrapper.vm.showConfirmationDialog).toBe(true); + it('clicking Delete opens a confirmation dialog', async () => { + const { user } = await makeWrapper(); + await user.click(screen.getByTestId('selectall')); + await user.click(screen.getByTestId('delete')); + + expect(await screen.findByText(/You cannot undo this action/i)).toBeInTheDocument(); }); - it('clicking CLOSE on delete confirmation dialog should close the dialog', async () => { - await wrapper.setData({ showConfirmationDialog: true }); - await wrapper.findComponent('[data-test="deleteconfirm"]').vm.$emit('cancel'); - expect(wrapper.vm.showConfirmationDialog).toBe(false); + it('clicking Cancel in the confirmation dialog closes it', async () => { + const { user } = await makeWrapper(); + await user.click(screen.getByTestId('selectall')); + await user.click(screen.getByTestId('delete')); + await screen.findByText(/You cannot undo this action/i); + + await user.click(screen.getByRole('button', { name: /Cancel/i })); + + await waitFor(() => { + expect(screen.queryByText(/You cannot undo this action/i)).not.toBeInTheDocument(); + }); }); - it('clicking DELETE PERMANENTLY on delete confirmation dialog should trigger deletion', async () => { - const selected = testChildren.map(c => c.id); - const deleteContentNodes = jest.spyOn(wrapper.vm, 'deleteContentNodes'); - deleteContentNodes.mockImplementation(() => Promise.resolve()); - await wrapper.setData({ selected, showConfirmationDialog: true }); - await wrapper.findComponent('[data-test="deleteconfirm"]').vm.$emit('submit'); - expect(deleteContentNodes).toHaveBeenCalledWith(selected); + it('clicking Delete permanently calls deleteContentNodes with the selected IDs', async () => { + const deleteContentNodes = jest + .spyOn(TrashModal.methods, 'deleteContentNodes') + .mockResolvedValue(); + + const { user } = await makeWrapper(); + + await user.click(screen.getByTestId('selectall')); + await user.click(screen.getByTestId('delete')); + await user.click(await screen.findByRole('button', { name: /Delete permanently/i })); + + await waitFor(() => { + expect(deleteContentNodes).toHaveBeenCalledWith(testChildren.map(c => c.id)); + }); + }); + + it('successful deletion triggers snackbar and reloads nodes', async () => { + jest.spyOn(TrashModal.methods, 'deleteContentNodes').mockResolvedValue(); + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(() => Promise.resolve()); + + const { user, loadNodesSpy } = await makeWrapper(); + await user.click(screen.getByTestId('selectall')); + await user.click(screen.getByTestId('delete')); + await user.click(await screen.findByRole('button', { name: /Delete permanently/i })); + + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith('showSnackbar', { + text: 'Permanently deleted', + }); + expect(loadNodesSpy).toHaveBeenCalled(); + }); }); }); describe('on restore', () => { - it('RESTORE button should be disabled if no items are selected', () => { - expect(wrapper.findComponent('[data-test="restore"]').vm.disabled).toBe(true); + it('Restore button is disabled when no items are selected', async () => { + await makeWrapper(); + expect(screen.getByTestId('restore')).toBeDisabled(); }); - it('RESTORE should set moveModalOpen to true', async () => { - const selected = testChildren.map(c => c.id); - await wrapper.setData({ selected }); - await wrapper.findComponent('[data-test="restore"]').trigger('click'); - expect(wrapper.vm.moveModalOpen).toBe(true); + it('clicking Restore opens the MoveModal', async () => { + const { user } = await makeWrapper(); + await user.click(screen.getByTestId('selectall')); + await user.click(screen.getByTestId('restore')); + expect(await screen.findByRole('button', { name: /Move here/i })).toBeInTheDocument(); }); + }); + + describe('selection count', () => { + it('shows selected item count in the bottom bar', async () => { + const { user } = await makeWrapper(); + + expect(screen.queryByText(/items selected/i)).not.toBeInTheDocument(); + + await user.click(screen.getByTestId('selectall')); + + expect(await screen.findByText(`${testChildren.length} items selected`)).toBeInTheDocument(); + }); + }); + + describe('pagination', () => { + it('shows a Show more button when there is more paginated content', async () => { + await makeWrapper([testChildren[0]]); + expect(await screen.findByRole('button', { name: /Show more/i })).toBeInTheDocument(); + }); + + it('clicking Show more calls loadContentNodes with pagination params', async () => { + const { user, loadContentNodesSpy } = await makeWrapper([testChildren[0]]); + const showMoreBtn = await screen.findByRole('button', { name: /Show more/i }); + + await user.click(showMoreBtn); - it('moveNoves should clear selected and previewNodeId', async () => { - const moveContentNodes = jest.spyOn(wrapper.vm, 'moveContentNodes'); - moveContentNodes.mockImplementation(() => Promise.resolve()); - wrapper.vm.moveNodes(); - expect(wrapper.vm.selected).toEqual([]); - expect(wrapper.vm.previewNodeId).toBe(null); + await waitFor(() => { + expect(loadContentNodesSpy).toHaveBeenCalledWith({ parent: TRASH_ID, page: 2 }); + }); }); }); });