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/StudioCollectionsTable.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue
new file mode 100644
index 0000000000..2f26b6d902
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/StudioCollectionsTable.vue
@@ -0,0 +1,345 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+
+ {{ $tr('saving') }}
+
+
+
+
+
+ {{ $formatNumber(content) }}
+
+
+
+
+
+
+ handleOptionSelect(option, content)"
+ />
+
+
+
+
+
+
+
+
+
+ {{ $tr('deleteChannelSetText') }}
+
+
+
+
+
+
+
+
+
+
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..90cd753ab2
--- /dev/null
+++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelSet/__tests__/StudioCollectionsTable.spec.js
@@ -0,0 +1,202 @@
+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 { nextTick } from 'vue';
+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 nextTick();
+
+ return { ...result, router };
+};
+
+describe('StudioCollectionsTable', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should display channel sets in table when data is loaded', async () => {
+ await renderComponent();
+
+ expect(screen.getByText('Test Collection 1')).toBeInTheDocument();
+ expect(screen.getByText('Test Collection 2')).toBeInTheDocument();
+ expect(screen.getByText('2')).toBeInTheDocument();
+ expect(screen.getByText('1')).toBeInTheDocument();
+ });
+
+ 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();
+ });
+
+ it('should open info modal when "Learn about collections" link is clicked', async () => {
+ const user = userEvent.setup();
+ await renderComponent();
+
+ const infoLink = screen.getByText('Learn 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();
+
+ 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();
+
+ 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',
+ }),
+ );
+ });
+});