diff --git a/.gitignore b/.gitignore index 9f9debd85c..8a711fa0cf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ var/ # Ignore editor / IDE related data .vscode/ +.claude/ # IntelliJ IDE, except project config .idea/ diff --git a/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js new file mode 100644 index 0000000000..cf306c65e6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/composables/useChannelList.js @@ -0,0 +1,44 @@ +import { ref, computed, onMounted, getCurrentInstance } from 'vue'; +import orderBy from 'lodash/orderBy'; + +/** + * 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} Loading state and filtered & sorted channels + */ +export function useChannelList(options = {}) { + const { listType, sortFields = ['modified'], orderFields = ['desc'] } = options; + + const instance = getCurrentInstance(); + const store = instance.proxy.$store; + + const loading = ref(false); + + const allChannels = computed(() => store.getters['channel/channels'] || []); + + const channels = computed(() => { + if (!allChannels.value || allChannels.value.length === 0) { + return []; + } + + const filtered = allChannels.value.filter(channel => channel[listType] && !channel.deleted); + + return orderBy(filtered, sortFields, orderFields); + }); + + onMounted(() => { + loading.value = true; + store.dispatch('channel/loadChannelList', { listType }).then(() => { + loading.value = false; + }); + }); + + return { + loading, + channels, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelList/router.js b/contentcuration/contentcuration/frontend/channelList/router.js index 01aa769d29..46c50e94e2 100644 --- a/contentcuration/contentcuration/frontend/channelList/router.js +++ b/contentcuration/contentcuration/frontend/channelList/router.js @@ -1,5 +1,7 @@ import VueRouter from 'vue-router'; -import ChannelList from './views/Channel/ChannelList'; +import StudioMyChannels from './views/Channel/StudioMyChannels'; +import StudioStarredChannels from './views/Channel/StudioStarredChannels'; +import StudioViewOnlyChannels from './views/Channel/StudioViewOnlyChannels'; import StudioCollectionsTable from './views/ChannelSet/StudioCollectionsTable'; import ChannelSetModal from './views/ChannelSet/ChannelSetModal'; import CatalogList from './views/Channel/CatalogList'; @@ -7,17 +9,14 @@ 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: [ { name: RouteNames.CHANNELS_EDITABLE, path: '/my-channels', - component: ChannelList, - props: { listType: ChannelListTypes.EDITABLE }, + component: StudioMyChannels, }, - { name: RouteNames.CHANNEL_SETS, path: '/collections', @@ -38,14 +37,12 @@ const router = new VueRouter({ { name: RouteNames.CHANNELS_STARRED, path: '/starred', - component: ChannelList, - props: { listType: ChannelListTypes.STARRED }, + component: StudioStarredChannels, }, { 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/CatalogList.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue index 6cd053fcbe..a910507578 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/CatalogList.vue @@ -26,9 +26,7 @@ fluid :style="`margin-top: ${offline ? 48 : 0}`" > - -

- {{ $tr('resultsText', { count: page.count }) }} -

- - {{ $tr('title') }} + +

+ {{ $tr('resultsText', { count: page.count }) }} +

+ +
+ +
+ - - - - + + + +
+ + @@ -136,11 +188,11 @@ import { RouteNames } from '../../constants'; import CatalogFilters from './CatalogFilters'; import CatalogFilterBar from './CatalogFilterBar'; - import ChannelItem from './ChannelItem'; - import LoadingText from 'shared/views/LoadingText'; + import StudioChannelCard from './StudioChannelCard'; + import ChannelStar from './ChannelStar'; + import ChannelTokenModal from 'shared/views/channel/ChannelTokenModal'; import Pagination from 'shared/views/Pagination'; import BottomBar from 'shared/views/BottomBar'; - import Checkbox from 'shared/views/form/Checkbox'; import ToolBar from 'shared/views/ToolBar'; import OfflineText from 'shared/views/OfflineText'; import { constantsTranslationMixin } from 'shared/mixins'; @@ -149,22 +201,23 @@ export default { name: 'CatalogList', components: { - ChannelItem, - LoadingText, + StudioChannelCard, + ChannelStar, + ChannelTokenModal, CatalogFilters, CatalogFilterBar, Pagination, BottomBar, - Checkbox, ToolBar, OfflineText, }, mixins: [channelExportMixin, constantsTranslationMixin], setup() { - const { windowIsSmall } = useKResponsiveWindow(); + const { windowIsSmall, windowBreakpoint } = useKResponsiveWindow(); return { windowIsSmall, + windowBreakpoint, }; }, data() { @@ -172,14 +225,19 @@ loading: true, loadError: false, selecting: false, + tokenChannelId: null, /** * jayoshih: router guard makes it difficult to track * differences between previous query params and new * query params, so just track it manually */ - previousQuery: this.$route.query, - + /** + * MisRob: Add 'page: 1' as default to prevent it from being + * added later and causing redundant $router watcher call when + * page initially loading (fixes loading state showing twice) + */ + previousQuery: { page: 1, ...this.$route.query }, /** * jayoshih: using excluded logic here instead of selected * to account for selections across pages (some channels @@ -190,10 +248,29 @@ }, computed: { ...mapGetters('channel', ['getChannels']), + ...mapGetters(['loggedIn']), ...mapState('channelList', ['page']), ...mapState({ offline: state => !state.connection.online, }), + skeletonsConfig() { + return [ + { + breakpoints: [0, 1, 2, 3, 4, 5, 6, 7], + count: 2, + orientation: 'vertical', + thumbnailDisplay: 'small', + thumbnailAlign: 'left', + thumbnailAspectRatio: '16:9', + minHeight: '380px', + }, + { + breakpoints: [3, 4, 5, 6, 7], + orientation: 'horizontal', + minHeight: '230px', + }, + ]; + }, selectAll: { get() { return this.selected.length === this.channels.length; @@ -216,9 +293,6 @@ debouncedSearch() { return debounce(this.loadCatalog, 1000); }, - detailsRouteName() { - return RouteNames.CATALOG_DETAILS; - }, channels() { // Sort again by the same ordering used on the backend - name. // Have to do this because of how we are getting the object data via getChannels. @@ -227,6 +301,13 @@ selectedCount() { return this.page.count - this.excluded.length; }, + isIndeterminate() { + return this.selected.length > 0 && this.selected.length < this.channels.length; + }, + tokenChannel() { + if (!this.tokenChannelId) return null; + return this.channels.find(c => c.id === this.tokenChannelId) || null; + }, }, watch: { $route(to) { @@ -245,11 +326,55 @@ this.previousQuery = { ...to.query }; }, }, - mounted() { + created() { this.loadCatalog(); }, methods: { ...mapActions('channelList', ['searchCatalog']), + getDropdownItems(channel) { + const items = []; + if (channel.source_url) { + items.push({ label: this.$tr('goToWebsite'), icon: 'openNewTab', value: 'source-url' }); + } + if (channel.demo_server_url) { + items.push({ label: this.$tr('viewContent'), icon: 'openNewTab', value: 'demo-url' }); + } + return items; + }, + handleDropdownSelect(option, channel) { + if (option.value === 'source-url') { + window.open(channel.source_url, '_blank'); + } else if (option.value === 'demo-url') { + window.open(channel.demo_server_url, '_blank'); + } + }, + onCardClick(channel) { + if (this.loggedIn) { + window.location.href = window.Urls.channel(channel.id); + } else { + this.$router.push({ + name: RouteNames.CHANNEL_DETAILS, + query: { + ...this.$route.query, + last: this.$route.name, + }, + params: { + channelId: channel.id, + }, + }); + } + }, + isChannelSelected(channel) { + return this.selected.includes(channel.id); + }, + handleSelectionToggle(channelId) { + const currentlySelected = this.selected; + if (currentlySelected.includes(channelId)) { + this.selected = currentlySelected.filter(id => id !== channelId); + } else { + this.selected = [...currentlySelected, channelId]; + } + }, loadCatalog() { this.loading = true; const params = { @@ -297,6 +422,7 @@ }, }, $trs: { + title: 'Content library', resultsText: '{count, plural,\n =1 {# result found}\n other {# results found}}', selectChannels: 'Download a summary of selected channels', cancelButton: 'Cancel', @@ -307,6 +433,10 @@ channelSelectionCount: '{count, plural,\n =1 {# channel selected}\n other {# channels selected}}', selectAll: 'Select all', + copyToken: 'Copy channel token', + moreOptions: 'More options', + goToWebsite: 'Go to source website', + viewContent: 'View channel on Kolibri', }, }; diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue index b8126e2069..1e074ab885 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/ChannelInvitation.vue @@ -131,17 +131,6 @@ diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioChannelsPage/__tests__/StudioChannelsPage.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioChannelsPage/__tests__/StudioChannelsPage.spec.js new file mode 100644 index 0000000000..a9ecfaa3a6 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioChannelsPage/__tests__/StudioChannelsPage.spec.js @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioChannelsPage from '../index.vue'; + +const router = new VueRouter(); + +const INVITATION = { + id: 'invitation-1', + accepted: false, + sender_name: 'User A', + channel_name: 'Channel A', + share_mode: 'edit', +}; + +const store = new Store({ + modules: { + channelList: { + namespaced: true, + getters: { + getInvitation: () => () => INVITATION, + }, + }, + }, +}); + +function renderComponent(props = {}, slots = {}) { + return render(StudioChannelsPage, { + props: { + loading: false, + ...props, + }, + slots, + routes: router, + store, + }); +} + +describe('StudioChannelsPage', () => { + it('shows header slot content', () => { + renderComponent({}, { header: 'My Channels' }); + expect(screen.getByText('My Channels')).toBeInTheDocument(); + }); + + it('shows cards slot content', async () => { + renderComponent({}, { cards: '
Card
' }); + expect(await screen.findByText('Card')).toBeInTheDocument(); + }); + + it('shows no channels message when not loading and no cards slot provided', () => { + renderComponent(); + expect(screen.getByText('No channels found')).toBeInTheDocument(); + }); + + it('does not show no channels message when loading', () => { + renderComponent({ loading: true }); + expect(screen.queryByText('No channels found')).not.toBeInTheDocument(); + }); + + it('does not show no channels message when cards slot is provided', () => { + renderComponent({ loading: false }, { cards: '
Card
' }); + expect(screen.queryByText('No channels found')).not.toBeInTheDocument(); + }); + + it('shows invitations when invitations prop provided', () => { + renderComponent({ invitations: [INVITATION] }); + expect(screen.getByText('You have 1 invitation')).toBeInTheDocument(); + expect(screen.getByText('User A has invited you to edit Channel A')).toBeInTheDocument(); + }); + + it('does not show invitations when invitations prop not provided', () => { + renderComponent(); + expect(screen.queryByText(/invitation/)).not.toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioChannelsPage/index.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioChannelsPage/index.vue new file mode 100644 index 0000000000..fce907f275 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioChannelsPage/index.vue @@ -0,0 +1,152 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js new file mode 100644 index 0000000000..ffb8e5889f --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/__tests__/StudioMyChannels.spec.js @@ -0,0 +1,260 @@ +import { render, screen, within, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioMyChannels from '../index.vue'; +import { ChannelListTypes } from 'shared/constants'; + +const originalLocation = window.location; + +const router = new VueRouter({ + routes: [ + { name: 'NEW_CHANNEL', path: '/new' }, + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +const CHANNELS = [ + { + id: 'channel-id-1', + name: 'Channel title 1', + language: 'en', + description: 'Channel description', + edit: true, + view: true, + bookmark: false, + published: true, + primary_token: 'abc12-3def4', + last_published: '2025-08-25T15:00:00Z', + modified: '2026-01-10T08:00:00Z', + source_url: 'https://source.example.com', + demo_server_url: 'https://demo.example.com', + }, + { + id: 'channel-id-2', + name: 'Channel title 2', + language: 'en', + description: 'Channel description', + edit: true, + view: true, + bookmark: true, + published: false, + last_published: null, + modified: '2026-01-10T08:00:00Z', + }, +]; + +const mockLoadChannelList = jest.fn(); +const mockLoadInvitationList = jest.fn(); +const mockDeleteChannel = jest.fn(); +const mockBookmarkChannel = jest.fn(); + +function createStore() { + return new Store({ + state: { + session: { + currentUser: { id: 'user-id' }, + }, + }, + actions: { + showSnackbarSimple: jest.fn(), + }, + modules: { + channel: { + namespaced: true, + getters: { + channels: () => CHANNELS, + getChannel: () => id => CHANNELS.find(c => c.id === id), + }, + actions: { + loadChannelList: mockLoadChannelList, + deleteChannel: mockDeleteChannel, + bookmarkChannel: mockBookmarkChannel, + }, + }, + channelList: { + namespaced: true, + getters: { + invitations: () => [], + getInvitation: () => () => {}, + }, + actions: { + loadInvitationList: mockLoadInvitationList, + }, + }, + }, + }); +} + +function renderComponent(props = {}) { + return render(StudioMyChannels, { + store: createStore(), + routes: router, + props: { + ...props, + }, + }); +} + +describe('StudioMyChannels', () => { + beforeEach(() => { + jest.clearAllMocks(); + router.push('/').catch(() => {}); + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('calls the load channel list action with correct parameters on mount', () => { + renderComponent(); + expect(mockLoadChannelList).toHaveBeenCalledTimes(1); + expect(mockLoadChannelList).toHaveBeenCalledWith(expect.anything(), { + listType: ChannelListTypes.EDITABLE, + }); + }); + + it('calls the load invitations action on mount', () => { + renderComponent(); + expect(mockLoadInvitationList).toHaveBeenCalled(); + }); + + it('shows the visually hidden title and all channel cards in correct semantic structure', async () => { + renderComponent(); + const title = screen.getByRole('heading', { name: /my channels/i }); + expect(title).toBeInTheDocument(); + expect(title.tagName).toBe('H1'); + expect(title).toHaveClass('visuallyhidden'); + + const cards = await screen.findAllByTestId('channel-card'); + expect(cards).toHaveLength(CHANNELS.length); + expect(cards[0]).toHaveTextContent('Channel title 1'); + expect(cards[0].querySelector('h2')).toBeInTheDocument(); + expect(cards[1]).toHaveTextContent('Channel title 2'); + expect(cards[1].querySelector('h2')).toBeInTheDocument(); + }); + + it('navigates to the new channel route when the new channel button clicked', async () => { + renderComponent(); + + // Wait for loading to complete by waiting for channel cards to appear, + // otherwise button click silently fails + await screen.findAllByTestId('channel-card'); + + const newChannelButton = screen.getByRole('button', { name: /new channel/i }); + + expect(router.currentRoute.path).toBe('/'); + await userEvent.click(newChannelButton); + await waitFor(() => { + expect(router.currentRoute.path).toBe('/new'); + }); + }); + + it('navigates to channel via window.location when card clicked', async () => { + delete window.location; + window.location = { ...originalLocation, href: '' }; + + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + await userEvent.click(cards[0]); + + expect(window.location.href).toBe('channel'); + }); + + describe('cards footer actions', () => { + async function openDropdownForCard(cardIndex = 0) { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const dropdownButtons = screen.getAllByRole('button', { name: 'More options' }); + await userEvent.click(dropdownButtons[cardIndex]); + return screen.getByRole('menu'); + } + + it('shows bookmark button', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const bookmarkButtons = screen.getAllByRole('button', { name: /starred channels/i }); + expect(bookmarkButtons).toHaveLength(CHANNELS.length); + }); + + it('shows more options dropdown button', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const dropdownButtons = screen.getAllByRole('button', { name: 'More options' }); + expect(dropdownButtons).toHaveLength(CHANNELS.length); + }); + + it('does not show remove option', async () => { + renderComponent(); + expect(screen.queryByText('Remove channel')).not.toBeInTheDocument(); + }); + + it('shows edit and delete dropdown options', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Edit channel details')).toBeInTheDocument(); + expect(within(menu).getByText('Delete channel')).toBeInTheDocument(); + }); + + it('navigates to edit page when edit option is clicked', async () => { + const menu = await openDropdownForCard(0); + expect(router.currentRoute.path).toBe('/'); + await userEvent.click(within(menu).getByText('Edit channel details')); + await waitFor(() => { + expect(router.currentRoute.path).toBe('/channel-id-1/edit'); + }); + }); + + it('opens delete modal when delete option is clicked', async () => { + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Delete channel')); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText('Delete this channel')).toBeInTheDocument(); + }); + + it('does not show copy token option when channel is not published', async () => { + const menu = await openDropdownForCard(1); + expect(within(menu).queryByText('Copy channel token')).not.toBeInTheDocument(); + }); + + it('shows copy token option when channel is published', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Copy channel token')).toBeInTheDocument(); + }); + + it('opens copy token modal when "Copy channel token" is clicked', async () => { + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Copy channel token')); + await waitFor(() => { + expect( + screen.getByText('Paste this token into Kolibri to import this channel'), + ).toBeInTheDocument(); + }); + }); + + it('shows source website option when channel has source_url', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Go to source website')).toBeInTheDocument(); + }); + + it('opens source URL in new tab when source website option is clicked', async () => { + window.open = jest.fn(); + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Go to source website')); + expect(window.open).toHaveBeenCalledWith('https://source.example.com', '_blank'); + }); + + it('shows view on Kolibri option when channel has demo_server_url', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('View channel on Kolibri')).toBeInTheDocument(); + }); + + it('opens demo URL in new tab when view on Kolibri is clicked', async () => { + window.open = jest.fn(); + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('View channel on Kolibri')); + expect(window.open).toHaveBeenCalledWith('https://demo.example.com', '_blank'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue new file mode 100644 index 0000000000..e260ffeed3 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioMyChannels/index.vue @@ -0,0 +1,199 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels/__tests__/StudioStarredChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels/__tests__/StudioStarredChannels.spec.js new file mode 100644 index 0000000000..f6725a9a96 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels/__tests__/StudioStarredChannels.spec.js @@ -0,0 +1,250 @@ +import { render, screen, within, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioStarredChannels from '../index'; +import { ChannelListTypes } from 'shared/constants'; + +const originalLocation = window.location; + +const router = new VueRouter({ + routes: [ + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +const CHANNELS = [ + { + id: 'channel-id-1', + name: 'Channel title 1', + language: 'en', + description: 'Channel description', + edit: true, + view: true, + bookmark: true, + published: true, + primary_token: 'abc12-3def4', + last_published: '2025-08-25T15:00:00Z', + modified: '2026-01-10T08:00:00Z', + source_url: 'https://source.example.com', + demo_server_url: 'https://demo.example.com', + }, + { + id: 'channel-id-2', + name: 'Channel title 2', + language: 'en', + description: 'Channel description', + edit: false, + view: true, + bookmark: true, + published: false, + last_published: null, + modified: '2026-01-10T08:00:00Z', + }, +]; + +const mockLoadChannelList = jest.fn(); +const mockDeleteChannel = jest.fn().mockResolvedValue(); +const mockRemoveViewer = jest.fn().mockResolvedValue(); +const mockBookmarkChannel = jest.fn().mockResolvedValue(); + +function createStore() { + return new Store({ + state: { + session: { + currentUser: { id: 'user-id' }, + }, + }, + actions: { + showSnackbarSimple: jest.fn(), + }, + modules: { + channel: { + namespaced: true, + getters: { + channels: () => CHANNELS, + getChannel: () => id => CHANNELS.find(c => c.id === id), + }, + actions: { + loadChannelList: mockLoadChannelList, + deleteChannel: mockDeleteChannel, + removeViewer: mockRemoveViewer, + bookmarkChannel: mockBookmarkChannel, + }, + }, + }, + }); +} + +function renderComponent(props = {}) { + return render(StudioStarredChannels, { + store: createStore(), + props: { + ...props, + }, + routes: router, + }); +} + +describe('StudioStarredChannels', () => { + beforeEach(() => { + jest.clearAllMocks(); + router.push('/').catch(() => {}); + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('calls the load channel list action with correct parameters on mount', () => { + renderComponent(); + expect(mockLoadChannelList).toHaveBeenCalledTimes(1); + expect(mockLoadChannelList).toHaveBeenCalledWith(expect.anything(), { + listType: ChannelListTypes.STARRED, + }); + }); + + it('shows the visually hidden title and all channel cards in correct semantic structure', async () => { + renderComponent(); + const title = screen.getByRole('heading', { name: /starred channels/i }); + expect(title).toBeInTheDocument(); + expect(title.tagName).toBe('H1'); + expect(title).toHaveClass('visuallyhidden'); + + const cards = await screen.findAllByTestId('channel-card'); + expect(cards).toHaveLength(CHANNELS.length); + expect(cards[0]).toHaveTextContent('Channel title 1'); + expect(cards[0].querySelector('h2')).toBeInTheDocument(); + expect(cards[1]).toHaveTextContent('Channel title 2'); + expect(cards[1].querySelector('h2')).toBeInTheDocument(); + }); + + it('navigates to channel via window.location when card clicked', async () => { + delete window.location; + window.location = { ...originalLocation, href: '' }; + + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + await userEvent.click(cards[0]); + + expect(window.location.href).toBe('channel'); + }); + + describe('cards footer actions', () => { + async function openDropdownForCard(cardIndex = 0) { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const dropdownButtons = screen.getAllByRole('button', { name: 'More options' }); + await userEvent.click(dropdownButtons[cardIndex]); + return screen.getByRole('menu'); + } + + it('shows bookmark button', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const bookmarkButtons = screen.getAllByRole('button', { name: /starred channels/i }); + expect(bookmarkButtons).toHaveLength(CHANNELS.length); + }); + + it('shows more options dropdown button', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const dropdownButtons = screen.getAllByRole('button', { name: 'More options' }); + expect(dropdownButtons).toHaveLength(CHANNELS.length); + }); + + describe('for editable channel', () => { + it('does not show remove option', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).queryByText('Remove channel')).not.toBeInTheDocument(); + }); + + it('shows edit and delete options', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Edit channel details')).toBeInTheDocument(); + expect(within(menu).getByText('Delete channel')).toBeInTheDocument(); + }); + + it('navigates to edit page when edit option is clicked', async () => { + const menu = await openDropdownForCard(0); + expect(router.currentRoute.path).toBe('/'); + await userEvent.click(within(menu).getByText('Edit channel details')); + await waitFor(() => { + expect(router.currentRoute.path).toBe('/channel-id-1/edit'); + }); + }); + + it('opens delete modal when delete option is clicked', async () => { + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Delete channel')); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Delete this channel')).toBeInTheDocument(); + }); + }); + + describe('for view-only channel', () => { + it('does not show edit and delete options', async () => { + const menu = await openDropdownForCard(1); + expect(within(menu).queryByText('Edit channel details')).not.toBeInTheDocument(); + expect(within(menu).queryByText('Delete channel')).not.toBeInTheDocument(); + }); + + it('shows remove option', async () => { + const menu = await openDropdownForCard(1); + expect(within(menu).getByText('Remove channel')).toBeInTheDocument(); + }); + + it('opens remove modal when remove option is clicked', async () => { + const menu = await openDropdownForCard(1); + await userEvent.click(within(menu).getByText('Remove channel')); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Remove from channel list')).toBeInTheDocument(); + }); + }); + + it('does not show copy token option when channel is not published', async () => { + const menu = await openDropdownForCard(1); + expect(within(menu).queryByText('Copy channel token')).not.toBeInTheDocument(); + }); + + it('shows copy token option when channel is published', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Copy channel token')).toBeInTheDocument(); + }); + + it('opens copy token modal when "Copy channel token" is clicked', async () => { + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Copy channel token')); + await waitFor(() => { + expect( + screen.getByText('Paste this token into Kolibri to import this channel'), + ).toBeInTheDocument(); + }); + }); + + it('shows source website option when channel has source_url', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Go to source website')).toBeInTheDocument(); + }); + + it('opens source URL in new tab when source website option is clicked', async () => { + window.open = jest.fn(); + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Go to source website')); + expect(window.open).toHaveBeenCalledWith('https://source.example.com', '_blank'); + }); + + it('shows view on Kolibri option when channel has demo_server_url', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('View channel on Kolibri')).toBeInTheDocument(); + }); + + it('opens demo URL in new tab when view on Kolibri is clicked', async () => { + window.open = jest.fn(); + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('View channel on Kolibri')); + expect(window.open).toHaveBeenCalledWith('https://demo.example.com', '_blank'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels/index.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels/index.vue new file mode 100644 index 0000000000..acaa41c15a --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioStarredChannels/index.vue @@ -0,0 +1,171 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels/__tests__/StudioViewOnlyChannels.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels/__tests__/StudioViewOnlyChannels.spec.js new file mode 100644 index 0000000000..0729879094 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels/__tests__/StudioViewOnlyChannels.spec.js @@ -0,0 +1,234 @@ +import { render, screen, within, waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import VueRouter from 'vue-router'; +import { Store } from 'vuex'; +import StudioViewOnlyChannels from '../index'; +import { ChannelListTypes } from 'shared/constants'; + +const originalLocation = window.location; + +const router = new VueRouter({ + routes: [ + { name: 'CHANNEL_DETAILS', path: '/:channelId/details' }, + { name: 'CHANNEL_EDIT', path: '/:channelId/:tab' }, + ], +}); + +const CHANNELS = [ + { + id: 'channel-id-1', + name: 'Channel title 1', + language: 'en', + description: 'Channel description', + edit: false, + view: true, + bookmark: false, + published: true, + primary_token: 'abc12-3def4', + last_published: '2025-08-25T15:00:00Z', + modified: '2026-01-10T08:00:00Z', + source_url: 'https://source.example.com', + demo_server_url: 'https://demo.example.com', + }, + { + id: 'channel-id-2', + name: 'Channel title 2', + language: 'en', + description: 'Channel description', + edit: false, + view: true, + bookmark: true, + published: false, + last_published: null, + modified: '2026-01-10T08:00:00Z', + }, +]; + +const mockLoadChannelList = jest.fn(); +const mockLoadInvitationList = jest.fn(); +const mockRemoveViewer = jest.fn().mockResolvedValue(); +const mockBookmarkChannel = jest.fn().mockResolvedValue(); + +function createStore() { + return new Store({ + state: { + session: { + currentUser: { id: 'user-id' }, + }, + }, + actions: { + showSnackbarSimple: jest.fn(), + }, + modules: { + channel: { + namespaced: true, + getters: { + channels: () => CHANNELS, + getChannel: () => id => CHANNELS.find(c => c.id === id), + }, + actions: { + loadChannelList: mockLoadChannelList, + removeViewer: mockRemoveViewer, + bookmarkChannel: mockBookmarkChannel, + }, + }, + channelList: { + namespaced: true, + getters: { + invitations: () => [], + getInvitation: () => () => {}, + }, + actions: { + loadInvitationList: mockLoadInvitationList, + }, + }, + }, + }); +} + +function renderComponent(props = {}) { + return render(StudioViewOnlyChannels, { + store: createStore(), + props: { + ...props, + }, + routes: router, + }); +} + +describe('StudioViewOnlyChannels', () => { + beforeEach(() => { + jest.clearAllMocks(); + router.push('/').catch(() => {}); + }); + + afterEach(() => { + window.location = originalLocation; + }); + + it('calls the load channel list action with correct parameters on mount', () => { + renderComponent(); + expect(mockLoadChannelList).toHaveBeenCalledTimes(1); + expect(mockLoadChannelList).toHaveBeenCalledWith(expect.anything(), { + listType: ChannelListTypes.VIEW_ONLY, + }); + }); + + it('calls the load invitations action on mount', () => { + renderComponent(); + expect(mockLoadInvitationList).toHaveBeenCalled(); + }); + + it('shows the visually hidden title and all channel cards in correct semantic structure', async () => { + renderComponent(); + const title = screen.getByRole('heading', { name: /view-only channels/i }); + expect(title).toBeInTheDocument(); + expect(title.tagName).toBe('H1'); + expect(title).toHaveClass('visuallyhidden'); + + const cards = await screen.findAllByTestId('channel-card'); + expect(cards).toHaveLength(CHANNELS.length); + expect(cards[0]).toHaveTextContent('Channel title 1'); + expect(cards[0].querySelector('h2')).toBeInTheDocument(); + expect(cards[1]).toHaveTextContent('Channel title 2'); + expect(cards[1].querySelector('h2')).toBeInTheDocument(); + }); + + it('navigates to channel via window.location when card clicked', async () => { + delete window.location; + window.location = { ...originalLocation, href: '' }; + + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + await userEvent.click(cards[0]); + + expect(window.location.href).toBe('channel'); + }); + + describe('cards footer actions', () => { + async function openDropdownForCard(cardIndex = 0) { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const dropdownButtons = screen.getAllByRole('button', { name: 'More options' }); + await userEvent.click(dropdownButtons[cardIndex]); + return screen.getByRole('menu'); + } + + it('shows bookmark button', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const bookmarkButtons = screen.getAllByRole('button', { name: /starred channels/i }); + expect(bookmarkButtons).toHaveLength(CHANNELS.length); + }); + + it('shows more options dropdown button', async () => { + renderComponent(); + await screen.findAllByTestId('channel-card'); + const dropdownButtons = screen.getAllByRole('button', { name: 'More options' }); + expect(dropdownButtons).toHaveLength(CHANNELS.length); + }); + + it('does not show edit and delete options', async () => { + renderComponent(); + expect(screen.queryByText('Edit channel details')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete channel')).not.toBeInTheDocument(); + }); + + it('shows remove option', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Remove channel')).toBeInTheDocument(); + }); + + it('opens remove modal when remove option is clicked', async () => { + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Remove channel')); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(within(dialog).getByText('Remove from channel list')).toBeInTheDocument(); + }); + + it('does not show copy token option when channel is not published', async () => { + const menu = await openDropdownForCard(1); + expect(within(menu).queryByText('Copy channel token')).not.toBeInTheDocument(); + }); + + it('shows copy token option when channel is published', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Copy channel token')).toBeInTheDocument(); + }); + + it('opens copy token modal when "Copy channel token" is clicked', async () => { + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Copy channel token')); + await waitFor(() => { + expect( + screen.getByText('Paste this token into Kolibri to import this channel'), + ).toBeInTheDocument(); + }); + }); + + it('shows source website option when channel has source_url', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('Go to source website')).toBeInTheDocument(); + }); + + it('opens source URL in new tab when source website option is clicked', async () => { + window.open = jest.fn(); + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('Go to source website')); + expect(window.open).toHaveBeenCalledWith('https://source.example.com', '_blank'); + }); + + it('shows view on Kolibri option when channel has demo_server_url', async () => { + const menu = await openDropdownForCard(0); + expect(within(menu).getByText('View channel on Kolibri')).toBeInTheDocument(); + }); + + it('opens demo URL in new tab when view on Kolibri is clicked', async () => { + window.open = jest.fn(); + const menu = await openDropdownForCard(0); + await userEvent.click(within(menu).getByText('View channel on Kolibri')); + expect(window.open).toHaveBeenCalledWith('https://demo.example.com', '_blank'); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels/index.vue b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels/index.vue new file mode 100644 index 0000000000..c90181002d --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/StudioViewOnlyChannels/index.vue @@ -0,0 +1,158 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js index d0bbd22e58..5d4fa7ec1f 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js +++ b/contentcuration/contentcuration/frontend/channelList/views/Channel/__tests__/catalogList.spec.js @@ -1,144 +1,254 @@ -import { render, screen, waitFor } from '@testing-library/vue'; +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 CatalogList from '../CatalogList'; +import { Store } from 'vuex'; +import CatalogList from '../CatalogList.vue'; import { RouteNames } from '../../../constants'; -const localVue = createLocalVue(); -localVue.use(Vuex); -localVue.use(VueRouter); +const originalLocation = window.location; -const mockChannels = [ +const CHANNELS = [ { id: 'channel-1', - name: 'Channel 1', + name: 'Channel title 1', description: 'Test channel 1', language: 'en', modified: new Date('2024-01-15'), last_published: new Date('2024-01-10'), + published: true, + primary_token: 'abc12-3def4', + source_url: 'https://source.example.com', + demo_server_url: 'https://demo.example.com', }, { id: 'channel-2', - name: 'Channel 2', + name: 'Channel title 2', description: 'Test channel 2', language: 'en', modified: new Date('2024-01-20'), last_published: new Date('2024-01-18'), + published: false, }, ]; -const mockChannelIds = mockChannels.map(c => c.id); +const CHANNEL_IDS = CHANNELS.map(c => c.id); -function createMockStore() { - const mockSearchCatalog = jest.fn(() => Promise.resolve()); +const router = new VueRouter({ + routes: [ + { name: RouteNames.CHANNEL_DETAILS, path: '/:channelId/details' }, + { name: RouteNames.CATALOG_ITEMS, path: '/catalog' }, + { name: RouteNames.CATALOG_DETAILS, path: '/catalog/:channelId' }, + ], +}); - return { - store: new Store({ - state: { - connection: { online: true }, - }, - getters: { - loggedIn: () => true, +const mockSearchCatalog = jest.fn(() => Promise.resolve()); + +function createStore({ loggedIn = true } = {}) { + return new Store({ + state: { + connection: { online: true }, + session: { + currentUser: { id: 'user-id' }, }, - actions: { - showSnackbar: jest.fn(), + }, + getters: { + loggedIn: () => loggedIn, + }, + modules: { + channel: { + namespaced: true, + state: { + channelsMap: Object.fromEntries(CHANNELS.map(c => [c.id, c])), + }, + getters: { + getChannels: state => ids => ids.map(id => state.channelsMap[id]).filter(Boolean), + getChannel: state => id => state.channelsMap[id], + }, }, - modules: { - channel: { - namespaced: true, - state: { - channelsMap: Object.fromEntries(mockChannels.map(c => [c.id, c])), - }, - getters: { - getChannels: state => ids => ids.map(id => state.channelsMap[id]).filter(Boolean), - getChannel: state => id => state.channelsMap[id], + channelList: { + namespaced: true, + state: { + page: { + count: CHANNEL_IDS.length, + results: CHANNEL_IDS, }, }, - channelList: { - namespaced: true, - state: { - page: { - count: mockChannelIds.length, - results: mockChannelIds, - }, - }, - actions: { - searchCatalog: mockSearchCatalog, - }, + actions: { + searchCatalog: mockSearchCatalog, }, }, - }), - mockSearchCatalog, - }; -} - -function createMockRouter() { - const router = new VueRouter({ - routes: [ - { name: RouteNames.CATALOG_ITEMS, path: '/catalog' }, - { name: RouteNames.CATALOG_DETAILS, path: '/catalog/:channelId' }, - ], + }, }); - router.push({ name: RouteNames.CATALOG_ITEMS }).catch(() => {}); - return router; } -function renderComponent() { - const { store, mockSearchCatalog } = createMockStore(); - const router = createMockRouter(); - - return { - ...render(CatalogList, { - localVue, - store, - router, - stubs: { CatalogFilters: true }, - }), - router, - mockSearchCatalog, - }; +const store = createStore(); + +function renderComponent({ storeOverrides } = {}) { + return render(CatalogList, { + store: storeOverrides || store, + routes: router, + stubs: { CatalogFilters: true }, + }); } describe('CatalogList', () => { beforeEach(() => { jest.clearAllMocks(); + router.push({ name: RouteNames.CATALOG_ITEMS }).catch(() => {}); + }); + + afterEach(() => { + window.location = originalLocation; }); - it('calls searchCatalog on mount', async () => { - const { mockSearchCatalog } = renderComponent(); + it('calls the searchCatalog action on mount', async () => { + renderComponent(); await waitFor(() => { expect(mockSearchCatalog).toHaveBeenCalled(); }); }); - it('renders title', async () => { + it('shows results found', async () => { renderComponent(); await waitFor(() => { expect(screen.getByText(/results found/i)).toBeInTheDocument(); }); }); - it('displays download button', async () => { + it('shows the selection link', async () => { renderComponent(); + await waitFor(() => { - expect(screen.getByTestId('select')).toBeInTheDocument(); + expect(screen.getByText('Download a summary of selected channels')).toBeInTheDocument(); }); }); - it('renders channel cards', async () => { + it('shows the visually hidden title and all channel cards in correct semantic structure', async () => { renderComponent(); + + const title = await screen.findByRole('heading', { name: /content library/i }); + expect(title).toBeInTheDocument(); + expect(title.tagName).toBe('H1'); + expect(title).toHaveClass('visuallyhidden'); + + const cards = await screen.findAllByTestId('channel-card'); + expect(cards).toHaveLength(CHANNELS.length); + expect(cards[0]).toHaveTextContent('Channel title 1'); + expect(cards[0].querySelector('h2')).toBeInTheDocument(); + expect(cards[1]).toHaveTextContent('Channel title 2'); + expect(cards[1].querySelector('h2')).toBeInTheDocument(); + }); + + it('navigates to channel via window.location when logged in and card is clicked', async () => { + delete window.location; + window.location = { ...originalLocation, href: '' }; + + renderComponent({ storeOverrides: createStore({ loggedIn: true }) }); + const cards = await screen.findAllByTestId('channel-card'); + await userEvent.click(cards[0]); + + expect(window.location.href).toBe('channel'); + }); + + it('navigates to channel details via router when not logged in and card is clicked', async () => { + renderComponent({ storeOverrides: createStore({ loggedIn: false }) }); + const cards = await screen.findAllByTestId('channel-card'); + await userEvent.click(cards[0]); + await waitFor(() => { - expect(screen.getByText('Channel 1')).toBeInTheDocument(); - expect(screen.getByText('Channel 2')).toBeInTheDocument(); + expect(router.currentRoute.name).toBe(RouteNames.CHANNEL_DETAILS); + expect(router.currentRoute.params.channelId).toBe('channel-1'); + }); + }); + + describe('cards footer actions', () => { + it('shows copy token button only for published channels', async () => { + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + expect(within(cards[0]).getByTestId('copy-button')).toBeInTheDocument(); + expect(within(cards[1]).queryByTestId('copy-button')).not.toBeInTheDocument(); + }); + + it('opens copy token modal when copy button is clicked', async () => { + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + const copyButton = within(cards[0]).getByTestId('copy-button'); + await userEvent.click(copyButton); + await waitFor(() => { + expect( + screen.getByText('Paste this token into Kolibri to import this channel'), + ).toBeInTheDocument(); + }); + }); + + it('shows bookmark button when logged in', async () => { + renderComponent({ storeOverrides: createStore({ loggedIn: true }) }); + const cards = await screen.findAllByTestId('channel-card'); + expect( + within(cards[0]).getByRole('button', { name: /starred channels/i }), + ).toBeInTheDocument(); + expect( + within(cards[1]).getByRole('button', { name: /starred channels/i }), + ).toBeInTheDocument(); + }); + + it('does not show bookmark button when logged out', async () => { + renderComponent({ storeOverrides: createStore({ loggedIn: false }) }); + const cards = await screen.findAllByTestId('channel-card'); + expect( + within(cards[0]).queryByRole('button', { name: /starred channels/i }), + ).not.toBeInTheDocument(); + expect( + within(cards[1]).queryByRole('button', { name: /starred channels/i }), + ).not.toBeInTheDocument(); + }); + + it('does not show dropdown button when channel has no source or demo urls', async () => { + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + expect(within(cards[0]).getByRole('button', { name: 'More options' })).toBeInTheDocument(); + expect( + within(cards[1]).queryByRole('button', { name: 'More options' }), + ).not.toBeInTheDocument(); + }); + + it('shows dropdown with source or demo options when available', async () => { + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + const dropdownButton = within(cards[0]).getByRole('button', { name: 'More options' }); + await userEvent.click(dropdownButton); + const menu = screen.getByRole('menu'); + expect(within(menu).getByText('Go to source website')).toBeInTheDocument(); + expect(within(menu).getByText('View channel on Kolibri')).toBeInTheDocument(); + }); + + it('opens source URL in new tab when source website option is clicked', async () => { + window.open = jest.fn(); + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + const dropdownButton = within(cards[0]).getByRole('button', { name: 'More options' }); + await userEvent.click(dropdownButton); + const menu = screen.getByRole('menu'); + await userEvent.click(within(menu).getByText('Go to source website')); + expect(window.open).toHaveBeenCalledWith('https://source.example.com', '_blank'); + }); + + it('opens demo URL in new tab when view on Kolibri is clicked', async () => { + window.open = jest.fn(); + renderComponent(); + const cards = await screen.findAllByTestId('channel-card'); + const dropdownButton = within(cards[0]).getByRole('button', { name: 'More options' }); + await userEvent.click(dropdownButton); + const menu = screen.getByRole('menu'); + await userEvent.click(within(menu).getByText('View channel on Kolibri')); + expect(window.open).toHaveBeenCalledWith('https://demo.example.com', '_blank'); }); }); describe('selection', () => { it('hides checkboxes and selection text initially', async () => { renderComponent(); - await waitFor(() => screen.getByTestId('select')); + await waitFor(() => screen.getByText('Download a summary of selected channels')); expect(screen.queryByRole('checkbox', { name: /select all/i })).not.toBeInTheDocument(); expect(screen.queryByText(/channels selected/i)).not.toBeInTheDocument(); @@ -149,7 +259,9 @@ describe('CatalogList', () => { const user = userEvent.setup(); renderComponent(); - const selectButton = await waitFor(() => screen.getByTestId('select')); + const selectButton = await waitFor(() => + screen.getByText('Download a summary of selected channels'), + ); await user.click(selectButton); await waitFor(() => { @@ -163,7 +275,9 @@ describe('CatalogList', () => { const user = userEvent.setup(); renderComponent(); - const selectButton = await waitFor(() => screen.getByTestId('select')); + const selectButton = await waitFor(() => + screen.getByText('Download a summary of selected channels'), + ); await user.click(selectButton); const cancelButton = await waitFor(() => screen.getByRole('button', { name: /cancel/i })); @@ -178,8 +292,8 @@ describe('CatalogList', () => { }); describe('search', () => { - it('triggers searchCatalog when query parameters change', async () => { - const { router, mockSearchCatalog } = renderComponent(); + it('calls the searchCatalog action when query parameters change', async () => { + renderComponent(); await waitFor(() => screen.getByText(/results found/i)); diff --git a/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue b/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue index c45f88b571..d46bf70733 100644 --- a/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue +++ b/contentcuration/contentcuration/frontend/channelList/views/ChannelListIndex.vue @@ -77,36 +77,6 @@ class="h-100" :class="isCatalogPage ? 'pa-0' : 'pa-4'" > - - - - - - - - import { mapActions, mapGetters, mapState } from 'vuex'; - import { - RouteNames, - ChannelInvitationMapping, - ListTypeToRouteMapping, - RouteToListTypeMapping, - } from '../constants'; + import { RouteNames, ChannelInvitationMapping, ListTypeToRouteMapping } from '../constants'; import ChannelListAppError from './ChannelListAppError'; - import ChannelInvitation from './Channel/ChannelInvitation'; - import StudioRaisedBox from 'shared/views/StudioRaisedBox.vue'; import { ChannelListTypes } from 'shared/constants'; import { constantsTranslationMixin, routerMixin } from 'shared/mixins'; import GlobalSnackbar from 'shared/views/GlobalSnackbar'; @@ -160,12 +123,10 @@ name: 'ChannelListIndex', components: { AppBar, - ChannelInvitation, ChannelListAppError, GlobalSnackbar, PolicyModals, StudioOfflineAlert, - StudioRaisedBox, }, mixins: [constantsTranslationMixin, routerMixin], computed: { @@ -186,9 +147,6 @@ isCatalogPage() { return this.$route.name === RouteNames.CATALOG_ITEMS; }, - currentListType() { - return RouteToListTypeMapping[this.$route.name]; - }, toolbarHeight() { return this.loggedIn && !this.isFAQPage ? 112 : 64; }, @@ -198,14 +156,6 @@ lists() { return Object.values(ChannelListTypes).filter(l => l !== 'public'); }, - invitationList() { - const invitations = this.invitations; - return ( - invitations.filter( - i => ChannelInvitationMapping[i.share_mode] === this.currentListType, - ) || [] - ); - }, invitationsByListCounts() { const inviteMap = {}; Object.values(ChannelListTypes).forEach(type => { @@ -221,9 +171,6 @@ catalogLink() { return { name: RouteNames.CATALOG_ITEMS }; }, - isChannelList() { - return this.lists.includes(this.currentListType); - }, homeLink() { return this.libraryMode ? window.Urls.base() : window.Urls.channels(); }, @@ -296,7 +243,6 @@ $trs: { channelSets: 'Collections', catalog: 'Content Library', - invitations: 'You have {count, plural,\n =1 {# invitation}\n other {# invitations}}', libraryTitle: 'Kolibri Content Library Catalog', frequentlyAskedQuestions: 'Frequently asked questions', }, @@ -341,10 +287,6 @@ overflow: auto; } - .invitation-list { - padding: 0; - } - .h-100 { height: 100%; } diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue index d9cce46e7f..7955d42ccb 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelTokenModal.vue @@ -4,13 +4,11 @@ v-if="dialog" :title="$tr('copyTitle')" :cancelText="$tr('close')" + :appendToOverlay="appendToOverlay" @cancel="dialog = false" >

{{ $tr('copyTokenInstructions') }}

- + @@ -18,23 +16,30 @@