From 98a8e6128ffe978afe7860acdeae3596f1dabff0 Mon Sep 17 00:00:00 2001 From: Yeshwanth munagapati Date: Mon, 17 Nov 2025 11:14:50 +0530 Subject: [PATCH 1/3] Cards in Starred channels changes --- .../channelList/composables/useChannelList.js | 131 +++++ .../frontend/channelList/router.js | 4 +- .../views/Channel/StudioMyChannels.vue | 496 +----------------- .../views/Channel/StudioStarredChannels.vue | 76 +++ .../__tests__/StudioStarredChannels.spec.js | 170 ++++++ .../Channel/components/StudioChannelCard.vue | 431 +++++++++++++++ .../views/Channel/styles/StudioChannels.scss | 107 ++++ 7 files changed, 937 insertions(+), 478 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__/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..54e8991ecb --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js @@ -0,0 +1,131 @@ +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']) + * @param {Function} options.filterFn - Additional filter function for channels + * @returns {Object} Channel list state and methods + */ +export function useChannelList(options = {}) { + const { listType, sortFields = ['modified'], orderFields = ['desc'], filterFn = null } = 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 []; + } + + let filtered = channels.value.filter(channel => channel[listType] && !channel.deleted); + + if (filterFn && typeof filterFn === 'function') { + filtered = filtered.filter(filterFn); + } + + 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%'; + }); + + // Methods + const loadData = async () => { + loading.value = true; + try { + await store.dispatch('channel/loadChannelList', { listType }); + } catch (error) { + loading.value = false; + } finally { + loading.value = false; + } + }; + + const newChannel = () => { + if (window.$analytics) { + window.$analytics.trackClick('channel_list', 'Create channel'); + } + + router.push({ + name: RouteNames.NEW_CHANNEL, + query: { last: route.name }, + }); + }; + + const goToChannel = channelId => { + window.location.href = window.Urls.channel(channelId); + }; + + const channelDetailsLink = channel => { + return { + name: RouteNames.CHANNEL_DETAILS, + query: { + ...route.query, + last: route.name, + }, + params: { + channelId: channel.id, + }, + }; + }; + + const channelEditLink = channel => { + return { + name: RouteNames.CHANNEL_EDIT, + query: { + ...route.query, + last: route.name, + }, + params: { + channelId: channel.id, + tab: 'edit', + }, + }; + }; + + onMounted(() => { + loadData(); + }); + + return { + loading, + channels, + listChannels, + hasChannels, + + maxWidthStyle, + + loadData, + newChannel, + goToChannel, + channelDetailsLink, + channelEditLink, + }; +} 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..f4288f0e48 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels.vue @@ -1,6 +1,6 @@ @@ -178,259 +50,33 @@ + + + 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..d14bbb2047 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/StudioStarredChannels.spec.js @@ -0,0 +1,170 @@ +import { render, fireEvent, screen, within } 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 starred channels', async () => { + renderComponent(store); + const card0 = await screen.findByTestId('card-0'); + const cardElements = screen.queryAllByTestId(testId => testId.startsWith('card-')); + + 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(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 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('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'); + }); +}); 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..142a28291f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/components/StudioChannelCard.vue @@ -0,0 +1,431 @@ + + + + + + + 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..0903bf8f87 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/styles/StudioChannels.scss @@ -0,0 +1,107 @@ +.studio-channels { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + min-height: calc(100% - 64px - 48px); +} + +.no-channels { + padding: 16px 0 0 16px; + font-size: 24px; +} + +.channels-body { + width: 100%; +} + +.new-channel { + display: flex; + justify-content: end; + width: 100%; + margin-top: 20px; +} + +.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; + } +} + +.cards-below-title { + font-size: 14px; +} + +.cards-resource { + span:first-child::after { + margin: 0 8px; + content: '•'; + } +} + +.cards-desc { + margin-top: 12px; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.footer-right, +.footer-left, +.last-updated { + display: flex; + align-items: center; +} + +.footer-left { + span { + font-size: 14px; + } + + div { + margin-left: 8px; + } +} + +.details-link { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + transition: background-color 0.2s ease; + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + + a { + width: 24px; + height: 24px; + } +} + +.details-icon { + width: 100%; + height: 100%; +} + +.img-placeholder-icon { + width: 50%; + min-width: 24px; + height: 50%; +} From 92294f7e872049164a70f88bd05a7684823c9fcd Mon Sep 17 00:00:00 2001 From: Yeshwanth munagapati Date: Mon, 17 Nov 2025 11:18:30 +0530 Subject: [PATCH 2/3] small change --- .../frontend/channelList/composables/useChannelList.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js index 54e8991ecb..5cd8f3cfe4 100644 --- a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js @@ -11,11 +11,10 @@ import { RouteNames } from '../constants'; * @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']) - * @param {Function} options.filterFn - Additional filter function for channels * @returns {Object} Channel list state and methods */ export function useChannelList(options = {}) { - const { listType, sortFields = ['modified'], orderFields = ['desc'], filterFn = null } = options; + const { listType, sortFields = ['modified'], orderFields = ['desc'] } = options; const instance = getCurrentInstance(); const store = instance.proxy.$store; @@ -34,11 +33,7 @@ export function useChannelList(options = {}) { return []; } - let filtered = channels.value.filter(channel => channel[listType] && !channel.deleted); - - if (filterFn && typeof filterFn === 'function') { - filtered = filtered.filter(filterFn); - } + const filtered = channels.value.filter(channel => channel[listType] && !channel.deleted); return orderBy(filtered, sortFields, orderFields); }); @@ -56,7 +51,6 @@ export function useChannelList(options = {}) { return '100%'; }); - // Methods const loadData = async () => { loading.value = true; try { From 4d1f21a2c16670b8236c59c743e6ebdc1917e42a Mon Sep 17 00:00:00 2001 From: Yeshwanth munagapati Date: Mon, 17 Nov 2025 16:12:48 +0530 Subject: [PATCH 3/3] small changes --- .../channelList/composables/useChannelList.js | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js index 5cd8f3cfe4..f35b0c707a 100644 --- a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js @@ -77,33 +77,6 @@ export function useChannelList(options = {}) { window.location.href = window.Urls.channel(channelId); }; - const channelDetailsLink = channel => { - return { - name: RouteNames.CHANNEL_DETAILS, - query: { - ...route.query, - last: route.name, - }, - params: { - channelId: channel.id, - }, - }; - }; - - const channelEditLink = channel => { - return { - name: RouteNames.CHANNEL_EDIT, - query: { - ...route.query, - last: route.name, - }, - params: { - channelId: channel.id, - tab: 'edit', - }, - }; - }; - onMounted(() => { loadData(); }); @@ -119,7 +92,5 @@ export function useChannelList(options = {}) { loadData, newChannel, goToChannel, - channelDetailsLink, - channelEditLink, }; }