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,