diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue index 00a741531d..7f3b1a923b 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue @@ -39,34 +39,36 @@ appearance="basic-link" @click="setSelection(true)" /> - - - - - + 0 && this.selected.length < this.channels.length; + }, }, watch: { $route(to) { @@ -236,6 +236,20 @@ }, methods: { ...mapActions('channelList', ['searchCatalog']), + isChannelSelected(channelId) { + return this.selected.includes(channelId); + }, + handleSelectionToggle(channelId) { + const currentlySelected = this.selected; + if (currentlySelected.includes(channelId)) { + this.selected = currentlySelected.filter(id => id !== channelId); + } else { + this.selected = [...currentlySelected, channelId]; + } + }, + toggleSelectAll() { + this.selectAll = !this.selectAll; + }, loadCatalog() { this.loading = true; const params = { diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js index 32c9de6843..e80dc113af 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js @@ -1,201 +1,231 @@ -import { mount } from '@vue/test-utils'; -import { factory } from '../../../store'; -import router from '../../../router'; -import { RouteNames } from '../../../constants'; +import { render, screen, fireEvent } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; import CatalogList from '../CatalogList'; +import { RouteNames } from '../../../constants'; -const store = factory(); +const mockChannels = [ + { + id: 'channel-1', + name: 'Testing Channel 1', + description: 'First test channel', + thumbnail: null, + thumbnail_encoding: {}, + language: 'en', + public: false, + version: 0, + last_published: null, + deleted: false, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + edit: true, + view: true, + modified: '2025-12-19T10:00:00Z', + primary_token: null, + count: 10, + unpublished_changes: true, + thumbnail_url: null, + published: false, + publishing: false, + bookmark: false, + }, + { + id: 'channel-2', + name: 'Testing Channel 2', + description: 'Second test channel', + thumbnail: null, + thumbnail_encoding: {}, + language: 'es', + public: false, + version: 0, + last_published: null, + deleted: false, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + edit: true, + view: true, + modified: '2025-12-19T10:00:00Z', + primary_token: null, + count: 20, + unpublished_changes: false, + thumbnail_url: null, + published: true, + publishing: false, + bookmark: false, + }, +]; + +const router = new VueRouter({ + routes: [ + { name: RouteNames.CATALOG_ITEMS, path: '/catalog' }, + { name: RouteNames.CATALOG_DETAILS, path: '/catalog/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); router.push({ name: RouteNames.CATALOG_ITEMS }); -const results = ['channel-1', 'channel-2']; - -function makeWrapper(computed = {}) { - const loadCatalog = jest.spyOn(CatalogList.methods, 'loadCatalog'); - loadCatalog.mockImplementation(() => Promise.resolve()); - - const downloadCSV = jest.spyOn(CatalogList.methods, 'downloadCSV'); - const downloadPDF = jest.spyOn(CatalogList.methods, 'downloadPDF'); +function renderComponent(customStore = null) { + const searchCatalog = jest.fn().mockResolvedValue(); + + const store = + customStore || + new Store({ + modules: { + channel: { + namespaced: true, + getters: { + getChannels: () => ids => { + return ids.map(id => mockChannels.find(c => c.id === id)).filter(Boolean); + }, + }, + actions: { + deleteChannel: jest.fn(), + removeViewer: jest.fn(), + }, + }, + channelList: { + namespaced: true, + state: { + page: { + count: mockChannels.length, + results: mockChannels.map(c => c.id), + page_number: 1, + total_pages: 1, + }, + }, + actions: { + searchCatalog, + }, + }, + }, + state: { + connection: { + online: true, + }, + session: { + currentUser: { + id: 'user-1', + }, + }, + }, + actions: { + showSnackbar: jest.fn(), + showSnackbarSimple: jest.fn(), + }, + }); - const wrapper = mount(CatalogList, { - router, + return render(CatalogList, { store, - computed: { - page() { - return { - count: results.length, - results, - }; - }, - ...computed, - }, + routes: router, stubs: { - CatalogFilters: true, + CatalogFilters: { + template: '
Filters
', + }, + Pagination: { + template: '
Pagination
', + props: ['pageNumber', 'totalPages'], + }, }, }); - return [wrapper, { loadCatalog, downloadCSV, downloadPDF }]; } -describe('catalogFilterBar', () => { - let wrapper, mocks; - - beforeEach(async () => { - [wrapper, mocks] = makeWrapper(); - await wrapper.setData({ loading: false }); +describe('CatalogList', () => { + it('renders catalog channels', async () => { + renderComponent(); + const cards = await screen.findAllByTestId('card'); + expect(cards.length).toBe(2); }); - it('should call loadCatalog on mount', () => { - [wrapper, mocks] = makeWrapper(); - expect(mocks.loadCatalog).toHaveBeenCalled(); + it('shows results count', async () => { + renderComponent(); + expect(await screen.findByText(/2 results found/i)).toBeInTheDocument(); }); - describe('on query change', () => { - const searchCatalogMock = jest.fn(); - - beforeEach(() => { - router.replace({ query: {} }).catch(() => {}); - searchCatalogMock.mockReset(); - [wrapper, mocks] = makeWrapper({ - debouncedSearch() { - return searchCatalogMock; + it('shows empty state when no channels found', async () => { + const emptyStore = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + getChannels: () => () => [], + }, + actions: { + deleteChannel: jest.fn(), + removeViewer: jest.fn(), + }, + }, + channelList: { + namespaced: true, + state: { + page: { + count: 0, + results: [], + page_number: 1, + total_pages: 0, + }, + }, + actions: { + searchCatalog: jest.fn().mockResolvedValue(), + }, + }, + }, + state: { + connection: { + online: true, + }, + session: { + currentUser: { + id: 'user-1', + }, }, - }); + }, + actions: { + showSnackbar: jest.fn(), + showSnackbarSimple: jest.fn(), + }, }); - it('should call debouncedSearch', async () => { - const keywords = 'search catalog test'; - router.push({ query: { keywords } }).catch(() => {}); - await wrapper.vm.$nextTick(); - expect(searchCatalogMock).toHaveBeenCalled(); - }); + renderComponent(emptyStore); + const cards = screen.queryAllByTestId('card'); + expect(cards.length).toBe(0); + expect(await screen.findByText(/0 results found/i)).toBeInTheDocument(); + }); - it('should reset excluded if a filter changed', async () => { - const keywords = 'search reset test'; - await wrapper.setData({ excluded: ['item 1'] }); - router.push({ query: { keywords } }).catch(() => {}); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.excluded).toEqual([]); - }); + describe('selection mode', () => { + it('shows select all checkbox when entering selection mode', async () => { + renderComponent(); - it('should keep excluded if page number changed', async () => { - await wrapper.setData({ excluded: ['item 1'] }); - router - .push({ - query: { - ...wrapper.vm.$route.query, - page: 2, - }, - }) - .catch(() => {}); - await wrapper.vm.$nextTick(); - expect(wrapper.vm.excluded).toEqual(['item 1']); - }); - }); + // Initially no select-all checkbox + expect(screen.queryByLabelText(/select all/i)).not.toBeInTheDocument(); - describe('download workflow', () => { - describe('toggling selection mode', () => { - it('checkboxes and toolbar should be hidden if selecting is false', () => { - expect(wrapper.findComponent('[data-test="checkbox"]').exists()).toBe(false); - expect(wrapper.findComponent('[data-test="toolbar"]').exists()).toBe(false); - }); - - it('should activate when select button is clicked', async () => { - await wrapper.findComponent('[data-test="select"]').trigger('click'); - expect(wrapper.vm.selecting).toBe(true); - }); - - it('clicking cancel should exit selection mode', async () => { - await wrapper.setData({ selecting: true }); - await wrapper.findComponent('[data-test="cancel"]').trigger('click'); - expect(wrapper.vm.selecting).toBe(false); - }); - - it('excluded should reset when selection mode is exited', async () => { - await wrapper.setData({ selecting: true, excluded: ['item-1', 'item-2'] }); - wrapper.vm.setSelection(false); - expect(wrapper.vm.excluded).toHaveLength(0); - }); - }); + // Click select button + const selectButton = await screen.findByText(/download a summary of selected channels/i); + await fireEvent.click(selectButton); - describe('selecting channels', () => { - const excluded = ['item-1']; - - beforeEach(async () => { - await wrapper.setData({ - selecting: true, - excluded, - }); - }); - - it('selecting all should select all items on the page', async () => { - await wrapper.setData({ excluded: excluded.concat(results) }); - wrapper.vm.selectAll = true; - expect(wrapper.vm.excluded).toEqual(excluded); - expect(wrapper.vm.selected).toEqual(results); - }); - - it('deselecting all should select all items on the page', () => { - wrapper.vm.selectAll = false; - expect(wrapper.vm.excluded).toEqual(excluded.concat(results)); - expect(wrapper.vm.selected).toEqual([]); - }); - - it('selecting a channel should remove it from excluded', async () => { - await wrapper.setData({ excluded: excluded.concat(results) }); - wrapper.vm.selected = [results[0]]; - expect(wrapper.vm.excluded).toEqual(excluded.concat([results[1]])); - expect(wrapper.vm.selected).toEqual([results[0]]); - }); - - it('deselecting a channel should add it to excluded', () => { - wrapper.vm.selected = [results[0]]; - expect(wrapper.vm.excluded).toEqual(excluded.concat([results[1]])); - expect(wrapper.vm.selected).toEqual([results[0]]); - }); + // Select-all checkbox should appear + expect(await screen.findByLabelText(/select all/i)).toBeInTheDocument(); + + // Toolbar with count should appear + expect(await screen.findByText(/2 channels selected/i)).toBeInTheDocument(); }); - describe('download csv', () => { - let downloadChannelsCSV; - const excluded = ['item-1', 'item-2']; - - beforeEach(async () => { - await wrapper.setData({ selecting: true, excluded }); - downloadChannelsCSV = jest.spyOn(wrapper.vm, 'downloadChannelsCSV'); - downloadChannelsCSV.mockImplementation(() => Promise.resolve()); - }); - - it('clicking download CSV should call downloadCSV', async () => { - mocks.downloadCSV.mockImplementationOnce(() => Promise.resolve()); - await wrapper.findComponent('[data-test="download-button"]').trigger('click'); - const menuOptions = wrapper.findAll('.ui-menu-option-content'); - await menuOptions.at(1).trigger('click'); - expect(mocks.downloadCSV).toHaveBeenCalled(); - }); - - it('clicking download PDF should call downloadPDF', async () => { - mocks.downloadPDF.mockImplementationOnce(() => Promise.resolve()); - await wrapper.findComponent('[data-test="download-button"]').trigger('click'); - const menuOptions = wrapper.findAll('.ui-menu-option-content'); - await menuOptions.at(0).trigger('click'); - expect(mocks.downloadPDF).toHaveBeenCalled(); - }); - - it('downloadCSV should call downloadChannelsCSV with current parameters', async () => { - const keywords = 'Download csv keywords test'; - router.replace({ query: { keywords } }); - await wrapper.vm.downloadCSV(); - expect(downloadChannelsCSV.mock.calls[0][0].keywords).toBe(keywords); - }); - - it('downloadCSV should call downloadChannelsCSV with list of excluded items', async () => { - await wrapper.vm.downloadCSV(); - expect(downloadChannelsCSV.mock.calls[0][0].excluded).toEqual(excluded); - }); - - it('downloadCSV should exit selection mode', async () => { - await wrapper.vm.downloadCSV(); - expect(wrapper.vm.selecting).toBe(false); - }); + it('exits selection mode when cancel is clicked', async () => { + renderComponent(); + + // Enter selection mode + const selectButton = await screen.findByText(/download a summary of selected channels/i); + await fireEvent.click(selectButton); + + // Verify we're in selection mode + expect(await screen.findByLabelText(/select all/i)).toBeInTheDocument(); + + // Click cancel + const cancelButton = await screen.findByText(/cancel/i); + await fireEvent.click(cancelButton); + + // Selection UI should disappear + expect(screen.queryByLabelText(/select all/i)).not.toBeInTheDocument(); }); }); }); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue index c0cd7271cf..c554f674aa 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue @@ -14,6 +14,17 @@ data-testid="card" @click="goToChannelRoute()" > +