From 0be0e93540829f7a9950521be0730b850ea89057 Mon Sep 17 00:00:00 2001 From: Bablu yeshwanth Date: Fri, 7 Nov 2025 18:21:12 +0530 Subject: [PATCH 01/26] [Remove Vuetify from Studio] Cards in My Channels --- .../frontend/channelList/router.js | 5 +- .../views/Channel/StudioMyChannels.vue | 546 ++++++++++++++++++ .../__tests__/StudioMyChannels.spec.js | 183 ++++++ 3 files changed, 731 insertions(+), 3 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index 533057ef1b..f9624f157e 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,5 +1,6 @@ import VueRouter from 'vue-router'; import ChannelList from './views/Channel/ChannelList'; +import StudioMyChannels from './views/Channel/StudioMyChannels.vue'; import ChannelSetList from './views/ChannelSet/ChannelSetList'; import ChannelSetModal from './views/ChannelSet/ChannelSetModal'; import CatalogList from './views/Channel/CatalogList'; @@ -14,10 +15,8 @@ const router = new VueRouter({ { name: RouteNames.CHANNELS_EDITABLE, path: '/my-channels', - component: ChannelList, - props: { listType: ChannelListTypes.EDITABLE }, + component: StudioMyChannels, }, - { name: RouteNames.CHANNEL_SETS, path: '/collections', diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue new file mode 100644 index 0000000000..4126d7de18 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue @@ -0,0 +1,546 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js new file mode 100644 index 0000000000..7999255177 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js @@ -0,0 +1,183 @@ +import { render, fireEvent, screen, within } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioMyChannels from '../StudioMyChannels.vue'; + +const mockChannels = [ + { + id: '1', + name: 'channel one', + edit: true, + published: true, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + deleted: false, + modified: 2, + last_published: 1, + description: 'Channel one description', + bookmark: true, + count: 5, + thumbnail_url: '', + language: 'en', + }, + { + id: '2', + name: 'channel two', + edit: true, + published: false, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + deleted: false, + modified: 2, + last_published: 1, + description: 'Channel two description', + bookmark: false, + count: 5, + thumbnail_url: '', + language: 'en', + }, + { + id: '3', + name: 'channel three', + edit: true, + published: true, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + deleted: false, + modified: 2, + last_published: 1, + description: 'Channel three description', + bookmark: false, + count: 5, + thumbnail_url: '', + language: 'en', + }, +]; + +const router = new VueRouter({ + routes: [ + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +function renderComponent(store) { + return render(StudioMyChannels, { + store, + routes: router, + }); +} + +const store = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + channels: () => { + return mockChannels; + }, + }, + actions: { + loadChannelList: jest.fn(), + createChannel: jest.fn(), + }, + }, + }, +}); + +describe('StudioMyChannels.vue', () => { + it('renders my channels', async () => { + renderComponent(store); + const card0 = await screen.findByTestId('card-0'); + const cardElements = screen.queryAllByTestId(testId => testId.startsWith('card-')); + expect(await screen.findByText('New channel')).toBeInTheDocument(); + + expect(card0).toHaveTextContent('channel one'); + expect(within(card0).getByTestId('details-button-0')).toBeInTheDocument(); + expect(within(card0).getByTestId('dropdown-button-0')).toBeInTheDocument(); + + expect(cardElements.length).toBe(3); + }); + + it(`Shows 'No channel found' when there are no channels`, async () => { + const store = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + channels: () => { + return []; + }, + }, + actions: { + loadChannelList: jest.fn(), + createChannel: jest.fn(), + }, + }, + }, + }); + renderComponent(store); + const cardElements = screen.queryAllByTestId(testId => testId.startsWith('card-')); + expect(cardElements.length).toBe(0); + }); + + it('open dropdown for published channel', async () => { + renderComponent(store); + const dropdownButton = await screen.findByTestId('dropdown-button-0'); + await fireEvent.click(dropdownButton); + expect(screen.getByText('Edit channel details')).toBeInTheDocument(); + expect(screen.getByText('Delete channel')).toBeInTheDocument(); + expect(screen.getByText('Go to source website')).toBeInTheDocument(); + expect(screen.getByText('View channel on Kolibri')).toBeInTheDocument(); + expect(screen.getByText('Copy channel token')).toBeInTheDocument(); + const listItems = document.querySelectorAll('.ui-focus-container-content li'); + expect(listItems.length).toBe(5); + }); + + it('open dropdown for unpulished channel', async () => { + renderComponent(store); + const dropdownButton = await screen.findByTestId('dropdown-button-1'); + await fireEvent.click(dropdownButton); + expect(screen.getByText('Edit channel details')).toBeInTheDocument(); + expect(screen.getByText('Delete channel')).toBeInTheDocument(); + expect(screen.getByText('Go to source website')).toBeInTheDocument(); + expect(screen.getByText('View channel on Kolibri')).toBeInTheDocument(); + const listItems = document.querySelectorAll('.ui-focus-container-content li'); + expect(listItems.length).toBe(4); + }); + + it('opens delete modal and close', async () => { + renderComponent(store); + const dropdownButton = await screen.findByTestId('dropdown-button-0'); + await fireEvent.click(dropdownButton); + const deleteButton = screen.getByText('Delete channel'); + await fireEvent.click(deleteButton); + let deleteModal = document.querySelector('[data-testid="delete-modal"]'); + expect(deleteModal).not.toBeNull(); + const closeDeleteModal = screen.getByText('Cancel'); + await fireEvent.click(closeDeleteModal); + deleteModal = document.querySelector('[data-testid="delete-modal"]'); + expect(deleteModal).toBeNull(); + }); + + it('open copy modal and close', async () => { + renderComponent(store); + const dropdownButton = await screen.findByTestId('dropdown-button-0'); + await fireEvent.click(dropdownButton); + const copyButton = screen.getByText('Copy channel token'); + await fireEvent.click(copyButton); + let copyModal = document.querySelector('[data-testid="copy-modal"]'); + expect(copyModal).not.toBeNull(); + const closeCopyModal = screen.getByText('Close'); + await fireEvent.click(closeCopyModal); + copyModal = document.querySelector('[data-testid="copy-modal"]'); + expect(copyModal).toBeNull(); + }); + + it('detail button takes to details page', async () => { + renderComponent(store); + const detailsButton = await screen.findByTestId('details-button-0'); + await fireEvent.click(detailsButton); + expect(router.currentRoute.path).toBe('/1/details'); + }); +}); From ad0d85fd2aefcbee510bc8f38b99a64a0f875657 Mon Sep 17 00:00:00 2001 From: Bablu yeshwanth Date: Mon, 8 Dec 2025 16:38:15 +0530 Subject: [PATCH 02/26] [Remove Vuetify from Studio] Cards in Starred channels changes --- .../channelList/composables/useChannelList.js | 79 +++ .../frontend/channelList/router.js | 4 +- .../views/Channel/StudioMyChannels.vue | 479 +----------------- .../views/Channel/StudioStarredChannels.vue | 74 +++ .../__tests__/StudioChannelCard.spec.js | 142 ++++++ .../__tests__/StudioMyChannels.spec.js | 77 +-- .../__tests__/StudioStarredChannels.spec.js | 117 +++++ .../Channel/components/StudioChannelCard.vue | 414 +++++++++++++++ .../views/Channel/styles/StudioChannels.scss | 32 ++ .../views/channel/ChannelTokenModal.vue | 5 + 10 files changed, 889 insertions(+), 534 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioChannelCard.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioStarredChannels.spec.js create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/styles/StudioChannels.scss diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js new file mode 100644 index 0000000000..ce0fbb575f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js @@ -0,0 +1,79 @@ +import { ref, computed, onMounted, getCurrentInstance } from 'vue'; +import { useRouter, useRoute } from 'vue-router/composables'; +import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; +import orderBy from 'lodash/orderBy'; +import { RouteNames } from '../constants'; + +/** + * Composable for channel list functionality + * + * @param {Object} options - Configuration options + * @param {string} options.listType - Type of channel list (from ChannelListTypes) + * @param {Array} options.sortFields - Fields to sort by (default: ['modified']) + * @param {Array} options.orderFields - Sort order (default: ['desc']) + * @returns {Object} Channel list state and methods + */ +export function useChannelList(options = {}) { + const { listType, sortFields = ['modified'], orderFields = ['desc'] } = options; + + const instance = getCurrentInstance(); + const store = instance.proxy.$store; + + const router = useRouter(); + const route = useRoute(); + + const { windowIsMedium, windowIsLarge, windowBreakpoint } = useKResponsiveWindow(); + + const loading = ref(false); + + const channels = computed(() => store.getters['channel/channels'] || []); + + const listChannels = computed(() => { + if (!channels.value || channels.value.length === 0) { + return []; + } + + const filtered = channels.value.filter(channel => channel[listType] && !channel.deleted); + + return orderBy(filtered, sortFields, orderFields); + }); + + const hasChannels = computed(() => listChannels.value.length > 0); + + const maxWidthStyle = computed(() => { + if (windowBreakpoint.value >= 5) return '50%'; + if (windowBreakpoint.value === 4) return '66.66%'; + if (windowBreakpoint.value === 3) return '83.33%'; + + if (windowIsLarge.value) return '50%'; + if (windowIsMedium.value) return '83.33%'; + + return '100%'; + }); + + const loadData = async () => { + loading.value = true; + try { + await store.dispatch('channel/loadChannelList', { listType }); + } catch (error) { + loading.value = false; + } finally { + loading.value = false; + } + }; + + onMounted(() => { + loadData(); + }); + + return { + loading, + channels, + listChannels, + hasChannels, + + maxWidthStyle, + + loadData, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index f9624f157e..02a6703fd5 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,6 +1,7 @@ import VueRouter from 'vue-router'; import ChannelList from './views/Channel/ChannelList'; import StudioMyChannels from './views/Channel/StudioMyChannels.vue'; +import StudioStarredChannels from './views/Channel/StudioStarredChannels.vue'; import ChannelSetList from './views/ChannelSet/ChannelSetList'; import ChannelSetModal from './views/ChannelSet/ChannelSetModal'; import CatalogList from './views/Channel/CatalogList'; @@ -37,8 +38,7 @@ const router = new VueRouter({ { name: RouteNames.CHANNELS_STARRED, path: '/starred', - component: ChannelList, - props: { listType: ChannelListTypes.STARRED }, + component: StudioStarredChannels, }, { name: RouteNames.CHANNELS_VIEW_ONLY, diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue index 4126d7de18..05c3c7f5f6 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue @@ -1,6 +1,6 @@ @@ -178,87 +49,31 @@ + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioChannelCard.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioChannelCard.spec.js new file mode 100644 index 0000000000..4591026480 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioChannelCard.spec.js @@ -0,0 +1,142 @@ +import { render, fireEvent, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioChannelCard from '../components/StudioChannelCard.vue'; + +const unpublishedChannel = { + id: '36b0a7090f174d488ae7526c9e15a00e', + name: 'channel one', + description: 'channel one description', + thumbnail_encoding: { + base64: '', + }, + thumbnail: '16bf072847b529c7528ded79aee808df.png', + language: 'ach', + public: false, + version: 0, + last_published: false, + deleted: false, + source_url: '', + demo_server_url: '', + edit: true, + view: false, + thumbnail_url: '', + published: false, + publishing: false, + staging_root_id: null, + __last_fetch: 1764224713827, + bookmark: true, +}; + +const publishedChannel = { + id: '36b0a7090f174d488ae7526c9e15a00e', + name: 'channel one', + description: 'channel one description', + thumbnail_encoding: { + base64: '', + }, + thumbnail: '16bf072847b529c7528ded79aee808df.png', + language: 'ach', + public: false, + version: 0, + last_published: '2025-08-25T15:54:56.622912Z', + modified: '2025-08-25T15:54:56.748897Z', + deleted: false, + source_url: '', + demo_server_url: '', + edit: true, + view: false, + thumbnail_url: '', + published: true, + publishing: false, + staging_root_id: null, + __last_fetch: 1764224713827, + bookmark: true, +}; + +const router = new VueRouter({ + routes: [ + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +function renderComponent(props = {}, store) { + return render(StudioChannelCard, { + store, + props, + routes: router, + }); +} + +const store = new Store({ + modules: { + channel: { + namespaced: true, + actions: { + deleteChannel: jest.fn(), + removeViewer: jest.fn(), + }, + }, + }, +}); + +describe('StudioChannelCard.vue', () => { + it('open dropdown for published channel', async () => { + renderComponent({ channel: publishedChannel }, store); + const card = await screen.findByTestId('card'); + expect(card).toHaveTextContent('channel one'); + const dropdownButton = await screen.findByTestId('dropdown-button'); + await fireEvent.click(dropdownButton); + expect(screen.getByText('Edit channel details')).toBeInTheDocument(); + expect(screen.getByText('Copy channel token')).toBeInTheDocument(); + expect(screen.getByText('Delete channel')).toBeInTheDocument(); + const listItems = document.querySelectorAll('.ui-focus-container-content li'); + expect(listItems.length).toBe(3); + }); + + it('open dropdown for unpulished channel', async () => { + renderComponent({ channel: unpublishedChannel }, store); + const dropdownButton = await screen.findByTestId('dropdown-button'); + await fireEvent.click(dropdownButton); + expect(screen.getByText('Edit channel details')).toBeInTheDocument(); + expect(screen.getByText('Delete channel')).toBeInTheDocument(); + const listItems = document.querySelectorAll('.ui-focus-container-content li'); + expect(listItems.length).toBe(2); + }); + + it('opens delete modal and close', async () => { + renderComponent({ channel: unpublishedChannel }, store); + const dropdownButton = await screen.findByTestId('dropdown-button'); + await fireEvent.click(dropdownButton); + const deleteButton = screen.getByText('Delete channel'); + await fireEvent.click(deleteButton); + let deleteModal = document.querySelector('[data-testid="delete-modal"]'); + expect(deleteModal).not.toBeNull(); + const closeDeleteModal = screen.getByText('Cancel'); + await fireEvent.click(closeDeleteModal); + deleteModal = document.querySelector('[data-testid="delete-modal"]'); + expect(deleteModal).toBeNull(); + }); + + it('open copy modal and close', async () => { + renderComponent({ channel: publishedChannel }, store); + const dropdownButton = await screen.findByTestId('dropdown-button'); + await fireEvent.click(dropdownButton); + const copyButton = screen.getByText('Copy channel token'); + await fireEvent.click(copyButton); + let copyModal = document.querySelector('[data-testid="copy-modal"]'); + expect(copyModal).not.toBeNull(); + const closeCopyModal = screen.getByText('Close'); + await fireEvent.click(closeCopyModal); + copyModal = document.querySelector('[data-testid="copy-modal"]'); + expect(copyModal).toBeNull(); + }); + + it('detail button takes to details page', async () => { + renderComponent({ channel: unpublishedChannel }, store); + const detailsButton = await screen.findByTestId('details-button'); + await fireEvent.click(detailsButton); + expect(router.currentRoute.path).toBe('/36b0a7090f174d488ae7526c9e15a00e/details'); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js index 7999255177..14885beee9 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioMyChannels.spec.js @@ -1,4 +1,4 @@ -import { render, fireEvent, screen, within } from '@testing-library/vue'; +import { render, screen } from '@testing-library/vue'; import VueRouter from 'vue-router'; import { Store } from 'vuex'; import StudioMyChannels from '../StudioMyChannels.vue'; @@ -88,15 +88,8 @@ const store = new Store({ describe('StudioMyChannels.vue', () => { it('renders my channels', async () => { renderComponent(store); - const card0 = await screen.findByTestId('card-0'); - const cardElements = screen.queryAllByTestId(testId => testId.startsWith('card-')); - expect(await screen.findByText('New channel')).toBeInTheDocument(); - - expect(card0).toHaveTextContent('channel one'); - expect(within(card0).getByTestId('details-button-0')).toBeInTheDocument(); - expect(within(card0).getByTestId('dropdown-button-0')).toBeInTheDocument(); - - expect(cardElements.length).toBe(3); + const cards = await screen.findAllByTestId('card'); + expect(cards.length).toBe(3); }); it(`Shows 'No channel found' when there are no channels`, async () => { @@ -117,67 +110,7 @@ describe('StudioMyChannels.vue', () => { }, }); renderComponent(store); - const cardElements = screen.queryAllByTestId(testId => testId.startsWith('card-')); - expect(cardElements.length).toBe(0); - }); - - it('open dropdown for published channel', async () => { - renderComponent(store); - const dropdownButton = await screen.findByTestId('dropdown-button-0'); - await fireEvent.click(dropdownButton); - expect(screen.getByText('Edit channel details')).toBeInTheDocument(); - expect(screen.getByText('Delete channel')).toBeInTheDocument(); - expect(screen.getByText('Go to source website')).toBeInTheDocument(); - expect(screen.getByText('View channel on Kolibri')).toBeInTheDocument(); - expect(screen.getByText('Copy channel token')).toBeInTheDocument(); - const listItems = document.querySelectorAll('.ui-focus-container-content li'); - expect(listItems.length).toBe(5); - }); - - it('open dropdown for unpulished channel', async () => { - renderComponent(store); - const dropdownButton = await screen.findByTestId('dropdown-button-1'); - await fireEvent.click(dropdownButton); - expect(screen.getByText('Edit channel details')).toBeInTheDocument(); - expect(screen.getByText('Delete channel')).toBeInTheDocument(); - expect(screen.getByText('Go to source website')).toBeInTheDocument(); - expect(screen.getByText('View channel on Kolibri')).toBeInTheDocument(); - const listItems = document.querySelectorAll('.ui-focus-container-content li'); - expect(listItems.length).toBe(4); - }); - - it('opens delete modal and close', async () => { - renderComponent(store); - const dropdownButton = await screen.findByTestId('dropdown-button-0'); - await fireEvent.click(dropdownButton); - const deleteButton = screen.getByText('Delete channel'); - await fireEvent.click(deleteButton); - let deleteModal = document.querySelector('[data-testid="delete-modal"]'); - expect(deleteModal).not.toBeNull(); - const closeDeleteModal = screen.getByText('Cancel'); - await fireEvent.click(closeDeleteModal); - deleteModal = document.querySelector('[data-testid="delete-modal"]'); - expect(deleteModal).toBeNull(); - }); - - it('open copy modal and close', async () => { - renderComponent(store); - const dropdownButton = await screen.findByTestId('dropdown-button-0'); - await fireEvent.click(dropdownButton); - const copyButton = screen.getByText('Copy channel token'); - await fireEvent.click(copyButton); - let copyModal = document.querySelector('[data-testid="copy-modal"]'); - expect(copyModal).not.toBeNull(); - const closeCopyModal = screen.getByText('Close'); - await fireEvent.click(closeCopyModal); - copyModal = document.querySelector('[data-testid="copy-modal"]'); - expect(copyModal).toBeNull(); - }); - - it('detail button takes to details page', async () => { - renderComponent(store); - const detailsButton = await screen.findByTestId('details-button-0'); - await fireEvent.click(detailsButton); - expect(router.currentRoute.path).toBe('/1/details'); + const cards = screen.queryAllByTestId('card'); + expect(cards.length).toBe(0); }); }); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioStarredChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioStarredChannels.spec.js new file mode 100644 index 0000000000..7d38818569 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioStarredChannels.spec.js @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioStarredChannels from '../StudioStarredChannels.vue'; + +const mockChannels = [ + { + id: '1', + name: 'channel one', + edit: true, + published: true, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + deleted: false, + modified: 2, + last_published: 1, + description: 'Channel one description', + bookmark: true, + count: 5, + thumbnail_url: '', + language: 'en', + }, + { + id: '2', + name: 'channel two', + edit: true, + published: false, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + deleted: false, + modified: 2, + last_published: 1, + description: 'Channel two description', + bookmark: false, + count: 5, + thumbnail_url: '', + language: 'en', + }, + { + id: '3', + name: 'channel three', + edit: true, + published: true, + source_url: 'https://example.com', + demo_server_url: 'https://demo.com', + deleted: false, + modified: 2, + last_published: 1, + description: 'Channel three description', + bookmark: false, + count: 5, + thumbnail_url: '', + language: 'en', + }, +]; + +const router = new VueRouter({ + routes: [ + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +function renderComponent(store) { + return render(StudioStarredChannels, { + store, + routes: router, + }); +} + +const store = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + channels: () => { + return mockChannels; + }, + }, + actions: { + loadChannelList: jest.fn(), + createChannel: jest.fn(), + }, + }, + }, +}); + +describe('StudioStarredChannels.vue', () => { + it('renders my channels', async () => { + renderComponent(store); + const cards = await screen.findAllByTestId('card'); + + expect(cards.length).toBe(1); + }); + + it(`Shows 'No channel found' when there are no channels`, async () => { + const store = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + channels: () => { + return []; + }, + }, + actions: { + loadChannelList: jest.fn(), + createChannel: jest.fn(), + }, + }, + }, + }); + renderComponent(store); + const cards = screen.queryAllByTestId('card'); + expect(cards.length).toBe(0); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue new file mode 100644 index 0000000000..007331ed5b --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue @@ -0,0 +1,414 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/styles/StudioChannels.scss b/contentcuration/contentcuration/frontend/channelList/views/Channel/styles/StudioChannels.scss new file mode 100644 index 0000000000..da4b164ec5 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/styles/StudioChannels.scss @@ -0,0 +1,32 @@ +.studio-channels { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + min-height: 100%; +} + +.no-channels { + padding: 16px 0 0 16px; + font-size: 24px; +} + +.channels-body { + width: 100%; +} + +.cards { + margin-top: 16px; + + /* check this below class, this should be coming form KDS */ + ::v-deep .visuallyhidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + border: 0; + } +} diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue index d9cce46e7f..afe78c7a28 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue @@ -4,6 +4,7 @@ v-if="dialog" :title="$tr('copyTitle')" :cancelText="$tr('close')" + :appendToOverlay="appendToOverlay" @cancel="dialog = false" >

{{ $tr('copyTokenInstructions') }}

@@ -30,6 +31,10 @@ type: Boolean, default: false, }, + appendToOverlay: { + type: Boolean, + default: false, + }, channel: { type: Object, required: true, From b7c3b454e0f166cf8571af2556e6d27cfce88d79 Mon Sep 17 00:00:00 2001 From: Bablu yeshwanth Date: Wed, 17 Dec 2025 17:05:44 +0530 Subject: [PATCH 03/26] [Remove Vuetify from Studio] Cards in View-only channels changes --- .../channelList/composables/useChannelList.js | 5 - .../frontend/channelList/router.js | 6 +- .../views/Channel/StudioViewOnlyChannels.vue | 74 +++++++++++++++ .../__tests__/StudioViewOnlyChannels.spec.js | 92 +++++++++++++++++++ .../Channel/components/StudioChannelCard.vue | 3 + 5 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels.vue create mode 100644 contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioViewOnlyChannels.spec.js diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js index ce0fbb575f..0a9e628c06 100644 --- a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js @@ -1,8 +1,6 @@ import { ref, computed, onMounted, getCurrentInstance } from 'vue'; -import { useRouter, useRoute } from 'vue-router/composables'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import orderBy from 'lodash/orderBy'; -import { RouteNames } from '../constants'; /** * Composable for channel list functionality @@ -19,9 +17,6 @@ export function useChannelList(options = {}) { const instance = getCurrentInstance(); const store = instance.proxy.$store; - const router = useRouter(); - const route = useRoute(); - const { windowIsMedium, windowIsLarge, windowBreakpoint } = useKResponsiveWindow(); const loading = ref(false); diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index 02a6703fd5..ebf39a4736 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,7 +1,7 @@ import VueRouter from 'vue-router'; -import ChannelList from './views/Channel/ChannelList'; import StudioMyChannels from './views/Channel/StudioMyChannels.vue'; import StudioStarredChannels from './views/Channel/StudioStarredChannels.vue'; +import StudioViewOnlyChannels from './views/Channel/StudioViewOnlyChannels.vue'; import ChannelSetList from './views/ChannelSet/ChannelSetList'; import ChannelSetModal from './views/ChannelSet/ChannelSetModal'; import CatalogList from './views/Channel/CatalogList'; @@ -9,7 +9,6 @@ import { RouteNames } from './constants'; import CatalogFAQ from './views/Channel/CatalogFAQ'; import ChannelModal from 'shared/views/channel/ChannelModal'; import ChannelDetailsModal from 'shared/views/channel/ChannelDetailsModal'; -import { ChannelListTypes } from 'shared/constants'; const router = new VueRouter({ routes: [ @@ -43,8 +42,7 @@ const router = new VueRouter({ { name: RouteNames.CHANNELS_VIEW_ONLY, path: '/view-only', - component: ChannelList, - props: { listType: ChannelListTypes.VIEW_ONLY }, + component: StudioViewOnlyChannels, }, { name: RouteNames.CHANNEL_DETAILS, diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels.vue new file mode 100644 index 0000000000..3f3a6f22f1 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels.vue @@ -0,0 +1,74 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioViewOnlyChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioViewOnlyChannels.spec.js new file mode 100644 index 0000000000..d1bd8361c1 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioViewOnlyChannels.spec.js @@ -0,0 +1,92 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioViewOnlyChannels from '../StudioViewOnlyChannels.vue'; + +const mockChannels = [ + { + id: 'a3e4bb9390034181b4f2dd4544b1041b', + name: 'Testing - 2', + description: 'Testing - 2', + thumbnail: null, + thumbnail_encoding: {}, + language: 'ceb', + public: false, + version: 0, + last_published: null, + ricecooker_version: null, + deleted: false, + source_url: '', + demo_server_url: '', + edit: false, + view: true, + modified: '2025-08-06T04:56:20.597463Z', + primary_token: null, + count: 0, + unpublished_changes: true, + thumbnail_url: null, + published: false, + publishing: false, + }, +]; + +const router = new VueRouter({ + routes: [ + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +function renderComponent(store) { + return render(StudioViewOnlyChannels, { + store, + routes: router, + }); +} + +const store = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + channels: () => { + return mockChannels; + }, + }, + actions: { + loadChannelList: jest.fn(), + createChannel: jest.fn(), + }, + }, + }, +}); + +describe('StudioViewOnlyChannels.vue', () => { + it('renders view only channels', async () => { + renderComponent(store); + const cards = await screen.findAllByTestId('card'); + expect(cards.length).toBe(1); + }); + + it(`Shows 'No channel found' when there are no channels`, async () => { + const store = new Store({ + modules: { + channel: { + namespaced: true, + getters: { + channels: () => { + return []; + }, + }, + actions: { + loadChannelList: jest.fn(), + createChannel: jest.fn(), + }, + }, + }, + }); + renderComponent(store); + const cards = screen.queryAllByTestId('card'); + expect(cards.length).toBe(0); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue index 007331ed5b..c0cd7271cf 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue @@ -262,6 +262,9 @@ if (!this.channel.published) { options = options.filter(item => item.value !== 'copy'); } + if (!this.channel.edit) { + options = options.filter(item => item.value !== 'edit'); + } if (this.channel.source_url === '') { options = options.filter(item => item.value !== 'source-url'); } From 07c0e13352f329db090ed66b077d492674d2b96f Mon Sep 17 00:00:00 2001 From: Michaela Robosova Date: Mon, 9 Feb 2026 09:05:21 +0100 Subject: [PATCH 04/26] Use 16:9 ratio for thumbnails --- .../Channel/components/StudioChannelCard.vue | 37 ++++++++++++++----- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue index c0cd7271cf..6d57f9d9f9 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue @@ -5,21 +5,31 @@ class="channel" :headingLevel="2" thumbnailDisplay="small" - :thumbnailSrc="thumbnailSrc" - :thumbnailAlign="'left'" - :thumbnailScaleType="'contain'" + thumbnailAlign="left" :orientation="windowIsSmall ? 'vertical' : 'horizontal'" :title="channel.name" :titleMaxLines="2" data-testid="card" @click="goToChannelRoute()" > -