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 @@
-
-
-
- |
- {{ channelSet.name }}
- |
-
-
-
- {{ $tr('saving') }}
- |
-
- {{ $formatNumber(channelCount) }}
- |
-
-
-
-
-
-
-
- {{ $tr('deleteChannelSetText') }}
-
- |
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $tr('noChannelSetsFound') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $tr('channelSetsDescriptionText') }}
-
{{ $tr('channelSetsInstructionsText') }}
-
- {{ $tr('channelSetsDisclaimer') }}
-
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ $tr('noChannelSetsFound') }}
+
+
+
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+
+ {{ $tr('saving') }}
+
+
+
+
+
+ {{ $formatNumber(content) }}
+
+
+
+
+
+
+ handleOptionSelect(option, content)"
+ />
+
+
+
+
+
+ handleOptionSelect(option, content)"
+ />
+
+
+
+
+
+
+
+ {{ $tr('deleteChannelSetText') }}
+
+
+
+
+
{{ $tr('channelSetsDescriptionText') }}
+
{{ $tr('channelSetsInstructionsText') }}
+
+ {{ $tr('channelSetsDisclaimer') }}
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+