diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index 533057ef1b..01aa769d29 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,6 +1,6 @@ import VueRouter from 'vue-router'; import ChannelList from './views/Channel/ChannelList'; -import ChannelSetList from './views/ChannelSet/ChannelSetList'; +import StudioCollectionsTable from './views/ChannelSet/StudioCollectionsTable'; import ChannelSetModal from './views/ChannelSet/ChannelSetModal'; import CatalogList from './views/Channel/CatalogList'; import { RouteNames } from './constants'; @@ -21,7 +21,7 @@ const router = new VueRouter({ { name: RouteNames.CHANNEL_SETS, path: '/collections', - component: ChannelSetList, + component: StudioCollectionsTable, }, { name: RouteNames.NEW_CHANNEL_SET, diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetItem.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetItem.vue deleted file mode 100644 index e6652dae20..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetItem.vue +++ /dev/null @@ -1,143 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetList.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetList.vue deleted file mode 100644 index a9cd0fe7ea..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/ChannelSetList.vue +++ /dev/null @@ -1,175 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue new file mode 100644 index 0000000000..fc9767b2e8 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue @@ -0,0 +1,455 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js new file mode 100644 index 0000000000..d17629b2d6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js @@ -0,0 +1,222 @@ +import { render, screen, within, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { createLocalVue } from '@vue/test-utils'; +import Vuex, { Store } from 'vuex'; +import VueRouter from 'vue-router'; +import StudioCollectionsTable from '../StudioCollectionsTable.vue'; +import { RouteNames } from '../../../constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); +localVue.use(VueRouter); + +const mockChannelSets = [ + { + id: 'collection-1', + name: 'Test Collection 1', + secret_token: 'token-123', + channels: ['channel-1', 'channel-2'], + }, + { + id: 'collection-2', + name: 'Test Collection 2', + secret_token: null, + channels: ['channel-3'], + }, +]; + +const mockActions = { + loadChannelSetList: jest.fn(() => Promise.resolve()), + deleteChannelSet: jest.fn(() => Promise.resolve()), +}; + +const createMockStore = () => { + return new Store({ + modules: { + channelSet: { + namespaced: true, + state: {}, + getters: { + channelSets: () => mockChannelSets, + getChannelSet: () => id => mockChannelSets.find(cs => cs.id === id), + }, + actions: mockActions, + }, + }, + actions: { + showSnackbarSimple: jest.fn(), + }, + }); +}; + +const renderComponent = async (options = {}) => { + const store = options.store || createMockStore(); + const router = new VueRouter({ + routes: [ + { + name: RouteNames.CHANNEL_SETS, + path: '/collections', + component: StudioCollectionsTable, + }, + { + name: RouteNames.NEW_CHANNEL_SET, + path: '/collections/new', + component: { template: '
New Channel Set
' }, + }, + { + name: RouteNames.CHANNEL_SET_DETAILS, + path: '/collections/:channelSetId', + component: { template: '
Channel Set Details
' }, + }, + ], + }); + + router.push({ name: RouteNames.CHANNEL_SETS }); + + const result = render(StudioCollectionsTable, { + localVue, + store, + router, + ...options, + }); + await waitFor(() => { + expect(mockActions.loadChannelSetList).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + + return { ...result, router }; +}; + +describe('StudioCollectionsTable', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should display channel sets in table when data is loaded', async () => { + await renderComponent(); + + const table = screen.getByRole('grid'); + const rows = within(table).getAllByRole('row').slice(1); + + expect(rows).toHaveLength(2); + + const row1Cells = within(rows[0]).getAllByRole('gridcell'); + expect(row1Cells[0]).toHaveTextContent('Test Collection 1'); + expect(row1Cells[1]).toHaveTextContent('Token'); + expect(row1Cells[2]).toHaveTextContent('2'); + + const row2Cells = within(rows[1]).getAllByRole('gridcell'); + expect(row2Cells[0]).toHaveTextContent('Test Collection 2'); + expect(row2Cells[1]).toHaveTextContent('Saving'); + expect(row2Cells[2]).toHaveTextContent('1'); + }); + + it('should display empty message when no collections are present', async () => { + const emptyStore = new Store({ + modules: { + channelSet: { + namespaced: true, + state: {}, + getters: { + channelSets: () => [], + getChannelSet: () => () => null, + }, + actions: mockActions, + }, + }, + }); + + await renderComponent({ store: emptyStore }); + + expect( + screen.getByText( + 'You can package together multiple channels to create a collection. The entire collection can then be imported to Kolibri at once by using a collection token.', + ), + ).toBeInTheDocument(); + expect(screen.getByText('Learn more about collections')).toBeInTheDocument(); + }); + + it('should open info modal when "Learn about collections" link is clicked', async () => { + const user = userEvent.setup(); + await renderComponent(); + + const infoLink = screen.getByText('Learn more about collections'); + await user.click(infoLink); + + expect(screen.getByRole('heading', { name: 'About collections' })).toBeInTheDocument(); + + const modal = screen.getByRole('dialog'); + expect( + within(modal).getByText( + 'A collection contains multiple Kolibri Studio channels that can be imported at one time to Kolibri with a single collection token.', + ), + ).toBeInTheDocument(); + }); + + it('should navigate to new channel set page when "New collection" button is clicked', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent(); + + const newCollectionButton = screen.getByRole('button', { name: /new collection/i }); + await user.click(newCollectionButton); + + expect(router.currentRoute.name).toBe(RouteNames.NEW_CHANNEL_SET); + }); + + it('should navigate to edit page when edit option is selected', async () => { + const user = userEvent.setup(); + const { router } = await renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Test Collection 1')).toBeInTheDocument(); + }); + + const optionsButtons = screen.getAllByRole('button', { name: /options/i }); + await user.click(optionsButtons[0]); + + const editOption = screen.getByText('Edit collection'); + await user.click(editOption); + + expect(router.currentRoute.name).toBe(RouteNames.CHANNEL_SET_DETAILS); + expect(router.currentRoute.params.channelSetId).toBe('collection-1'); + }); + + it('should call delete action when delete is confirmed', async () => { + const user = userEvent.setup(); + await renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Test Collection 1')).toBeInTheDocument(); + }); + + const optionsButtons = screen.getAllByRole('button', { name: /options/i }); + await user.click(optionsButtons[0]); + + const deleteOption = screen.getByText('Delete collection'); + await user.click(deleteOption); + + const deleteConfirmationModal = screen.getByRole('dialog'); + + expect( + within(deleteConfirmationModal).getByRole('heading', { + name: 'Delete collection', + }), + ).toBeInTheDocument(); + + const deleteButton = within(deleteConfirmationModal).getByRole('button', { + name: 'Delete collection', + }); + await user.click(deleteButton); + + expect(mockActions.deleteChannelSet).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + id: 'collection-1', + name: 'Test Collection 1', + }), + ); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetItem.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetItem.spec.js deleted file mode 100644 index 59c67b231a..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetItem.spec.js +++ /dev/null @@ -1,102 +0,0 @@ -import { render, screen } from '@testing-library/vue'; -import userEvent from '@testing-library/user-event'; -import { createLocalVue } from '@vue/test-utils'; -import Vuex, { Store } from 'vuex'; -import VueRouter from 'vue-router'; -import ChannelSetItem from '../ChannelSetItem.vue'; -import { RouteNames } from '../../../constants'; - -const localVue = createLocalVue(); -localVue.use(Vuex); -localVue.use(VueRouter); - -const channelSet = { - id: 'testing', - name: 'Test Collection', - channels: [], - secret_token: '1234567890', -}; - -const mockActions = { - deleteChannelSet: jest.fn(() => Promise.resolve()), -}; - -const createMockStore = () => { - return new Store({ - modules: { - channelSet: { - namespaced: true, - state: { - channelSetsMap: { - [channelSet.id]: channelSet, - }, - }, - getters: { - getChannelSet: state => id => state.channelSetsMap[id], - }, - actions: mockActions, - }, - }, - }); -}; - -const renderComponent = () => { - const store = createMockStore(); - const router = new VueRouter({ - routes: [ - { - name: RouteNames.CHANNEL_SET_DETAILS, - path: '/channels/collections/:channelSetId', - }, - ], - }); - - const result = render(ChannelSetItem, { - localVue, - store, - router, - props: { - channelSetId: channelSet.id, - }, - }); - - return { ...result, router }; -}; - -describe('channelSetItem', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('clicking the edit option should navigate to channel set details', async () => { - const user = userEvent.setup(); - const { router } = renderComponent(); - - const optionsButton = screen.getByRole('button', { name: /options/i }); - await user.click(optionsButton); - - const editOption = screen.getByText(/edit collection/i); - await user.click(editOption); - - expect(router.currentRoute.path).toBe(`/channels/collections/${channelSet.id}`); - }); - - it('clicking delete button in dialog should delete the channel set', async () => { - const user = userEvent.setup(); - renderComponent(); - - const optionsButton = screen.getByRole('button', { name: /options/i }); - await user.click(optionsButton); - - const deleteOption = screen.getByText(/delete collection/i); - await user.click(deleteOption); - - const modalText = /are you sure you want to delete this collection/i; - expect(screen.getByText(modalText)).toBeInTheDocument(); - - const confirmButton = screen.getByRole('button', { name: /delete collection/i }); - await user.click(confirmButton); - - expect(mockActions.deleteChannelSet).toHaveBeenCalledWith(expect.any(Object), channelSet); - }); -}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetList.spec.js b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetList.spec.js deleted file mode 100644 index 61fb53847a..0000000000 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/channelSetList.spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { factory } from '../../../store'; -import router from '../../../router'; -import { RouteNames } from '../../../constants'; -import ChannelSetList from '../ChannelSetList.vue'; - -const store = factory(); - -function makeWrapper() { - router.push({ - name: RouteNames.CHANNEL_SETS, - }); - return mount(ChannelSetList, { store, router }); -} - -describe('channelSetList', () => { - let wrapper; - - beforeEach(async () => { - wrapper = makeWrapper(); - await wrapper.setData({ loading: false }); - }); - - it('should open a new channel set modal when new set button is clicked', async () => { - const push = jest.fn(); - wrapper.vm.$router.push = push; - await wrapper.find('[data-test="add-channelset"]').trigger('click'); - expect(push).toHaveBeenCalledWith({ - name: RouteNames.NEW_CHANNEL_SET, - }); - }); -}); diff --git a/contentcuration/contentcuration/frontend/settings/pages/Account/StudioCopyToken.vue b/contentcuration/contentcuration/frontend/settings/pages/Account/StudioCopyToken.vue deleted file mode 100644 index cce976d069..0000000000 --- a/contentcuration/contentcuration/frontend/settings/pages/Account/StudioCopyToken.vue +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - - diff --git a/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue b/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue index b667527723..6f3be20ffa 100644 --- a/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue +++ b/contentcuration/contentcuration/frontend/settings/pages/Account/index.vue @@ -155,10 +155,10 @@ + + +