From b177b0f716b5f9a242b0651d2c1da334bdd416c3 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Mon, 27 Oct 2025 09:17:43 +0530 Subject: [PATCH 1/9] [Remove Vuetify from Studio] Replace FullscreenModal with StudioImmersiveModal in ChannelDetailsModal --- .../shared/views/StudioImmersiveModal.vue | 214 ++++++++++++++++++ .../views/channel/ChannelDetailsModal.vue | 97 ++++---- 2 files changed, 263 insertions(+), 48 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue new file mode 100644 index 0000000000..41f7a6a978 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue @@ -0,0 +1,214 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue index 65c886f2c7..e3581cafaf 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue @@ -1,6 +1,6 @@ - - - - - - @@ -61,16 +43,16 @@ import { mapActions, mapGetters } from 'vuex'; import { channelExportMixin } from './mixins'; import DetailsPanel from 'shared/views/details/DetailsPanel.vue'; + import StudioLargeLoader from 'shared/views/StudioLargeLoader'; + import StudioImmersiveModal from 'shared/views/StudioImmersiveModal'; import { routerMixin } from 'shared/mixins'; - import LoadingText from 'shared/views/LoadingText'; - import FullscreenModal from 'shared/views/FullscreenModal'; export default { name: 'ChannelDetailsModal', components: { DetailsPanel, - LoadingText, - FullscreenModal, + StudioLargeLoader, + StudioImmersiveModal, }, mixins: [routerMixin, channelExportMixin], props: { @@ -98,6 +80,12 @@ } return { ...this.channel, ...this.details }; }, + downloadOptions() { + return [ + { label: this.$tr('downloadPDF'), value: 'pdf' }, + { label: this.$tr('downloadCSV'), value: 'csv' }, + ]; + }, backLink() { return { name: this.$route.query.last, @@ -135,6 +123,13 @@ }, methods: { ...mapActions('channel', ['loadChannel', 'loadChannelDetails']), + handleDownloadSelect(option) { + if (option.value === 'pdf') { + this.generatePDF(); + } else if (option.value === 'csv') { + this.generateCSV(); + } + }, async generatePDF() { this.$analytics.trackEvent('channel_details', 'Download PDF', { id: this.channelId, @@ -187,14 +182,20 @@ From f7d2dc4fe5304066feed633aa2d144a4befe22c1 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Tue, 28 Oct 2025 17:04:41 +0530 Subject: [PATCH 3/9] Refactor ChannelDetailsModal tests to use Testing Library and improve structure --- .../__tests__/channelDetailsModal.spec.js | 273 +++++++++++++----- 1 file changed, 204 insertions(+), 69 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js index 93e507bfa2..dbab11ad33 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js @@ -1,99 +1,234 @@ -import { mount } from '@vue/test-utils'; +import { render, screen, 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 ChannelDetailsModal from './../ChannelDetailsModal'; -import storeFactory from 'shared/vuex/baseStore'; - -const PARENTROUTE = 'Parent route'; -const TESTROUTE = 'test channel details modal route'; -const router = new VueRouter({ - routes: [ - { - name: PARENTROUTE, - path: '/', - props: true, - children: [ - { - name: TESTROUTE, - path: '/testroute', - props: true, - component: ChannelDetailsModal, +import ChannelDetailsModal from '../ChannelDetailsModal.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); +localVue.use(VueRouter); + +const channelId = '11111111111111111111111111111111'; +const testChannel = { + id: channelId, + name: 'Test Channel', + description: 'Test Description', +}; + +const testDetails = { + count: 10, + size: 1024, +}; + +const mockActions = { + loadChannel: jest.fn(() => Promise.resolve(testChannel)), + loadChannelDetails: jest.fn(() => Promise.resolve(testDetails)), +}; + +const createMockStore = () => { + return new Store({ + state: { + connection: { + online: true, + }, + }, + modules: { + channel: { + namespaced: true, + state: { + channelsMap: { + [channelId]: testChannel, + }, + }, + getters: { + getChannel: state => id => state.channelsMap[id], }, - ], + actions: mockActions, + }, }, - ], -}); + }); +}; -const store = storeFactory(); -const channelId = '11111111111111111111111111111111'; +const PARENT_ROUTE = 'parent-route'; +const TEST_ROUTE = 'channel-details'; -function makeWrapper() { - return mount(ChannelDetailsModal, { - router, +const createRouter = () => { + return new VueRouter({ + routes: [ + { + name: PARENT_ROUTE, + path: '/', + children: [ + { + name: TEST_ROUTE, + path: '/channel/:channelId', + component: ChannelDetailsModal, + }, + ], + }, + ], + }); +}; + +const renderComponent = (options = {}) => { + const store = createMockStore(); + const router = createRouter(); + + router.push({ + name: TEST_ROUTE, + params: { channelId }, + query: { last: PARENT_ROUTE }, + }); + + return render(ChannelDetailsModal, { + localVue, store, - propsData: { + router, + props: { channelId, }, - computed: { - channel() { - return { - name: 'test', - }; + stubs: { + StudioImmersiveModal: { + template: ` +
+ +
+ +
+ `, + props: ['value'], + }, + StudioLargeLoader: { + template: '
Loading...
', + }, + DetailsPanel: { + template: '
Details Panel
', + props: ['details', 'loading'], + }, + KButton: { + template: ` + + `, + props: ['text', 'primary', 'hasDropdown'], + }, + KDropdownMenu: { + template: ` +
+ +
+ `, + props: ['options'], + }, + }, + mocks: { + $analytics: { + trackAction: jest.fn(), + trackEvent: jest.fn(), }, }, + ...options, }); -} - -describe('channelDetailsModal', () => { - let wrapper; +}; +describe('ChannelDetailsModal', () => { beforeEach(() => { - router.push({ - name: TESTROUTE, - params: { - channelId, - }, + jest.clearAllMocks(); + }); + + it('should display loading indicator initially', () => { + renderComponent(); + expect(screen.getByTestId('loader')).toBeInTheDocument(); + }); + + it('should display channel name in header', async () => { + renderComponent(); + await waitFor(() => { + expect(screen.getByText(testChannel.name)).toBeInTheDocument(); }); - wrapper = makeWrapper(); }); - it('clicking close should close the modal', async () => { - await wrapper.findComponent('[data-test="close"]').trigger('click'); - expect(wrapper.vm.dialog).toBe(false); + it('clicking close button should close the modal', async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText(testChannel.name)).toBeInTheDocument(); + }); + + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + await user.click(closeButton); + }); + + it('pressing ESC key should close the modal', async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText(testChannel.name)).toBeInTheDocument(); + }); + + await user.keyboard('{Escape}'); + + // Modal should remain rendered but dialog value changes + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); }); - it('clicking download CSV button should call generateChannelsCSV', async () => { - await wrapper.setData({ loading: false }); - const generateChannelsCSV = jest.spyOn(wrapper.vm, 'generateChannelsCSV'); - generateChannelsCSV.mockImplementation(() => Promise.resolve()); - await wrapper.findComponent('[data-test="dl-csv"]').trigger('click'); - expect(generateChannelsCSV).toHaveBeenCalledWith([wrapper.vm.channelWithDetails]); + it('should display download button after loading', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByText('Download channel summary')).toBeInTheDocument(); + }); }); - describe('load', () => { - let loadChannel; - let loadChannelDetails; + it('should display download button with dropdown functionality', async () => { + renderComponent(); - beforeEach(() => { - loadChannel = jest.spyOn(wrapper.vm, 'loadChannel'); - loadChannelDetails = jest.spyOn(wrapper.vm, 'loadChannelDetails'); + await waitFor(() => { + expect(screen.getByText('Download channel summary')).toBeInTheDocument(); }); - it('should automatically close if loadChannel does not find a channel', async () => { - await wrapper.vm.load(); - expect(wrapper.vm.dialog).toBe(false); + // Verify button is clickable + const downloadButton = screen.getByText('Download channel summary'); + expect(downloadButton).toBeInTheDocument(); + }); + + it('should display details panel after loading', async () => { + renderComponent(); + + await waitFor(() => { + expect(screen.getByTestId('details-panel')).toBeInTheDocument(); }); + }); + + it('should call loadChannel and loadChannelDetails on mount', async () => { + renderComponent(); - it('load should call loadChannel and loadChannelDetails', async () => { - await wrapper.vm.load(); - expect(loadChannel).toHaveBeenCalled(); - expect(loadChannelDetails).toHaveBeenCalled(); + await waitFor(() => { + expect(mockActions.loadChannel).toHaveBeenCalledWith(expect.any(Object), channelId); + expect(mockActions.loadChannelDetails).toHaveBeenCalledWith(expect.any(Object), channelId); }); + }); + + it('should track analytics on mount', async () => { + renderComponent(); - it('load should update document.title', async () => { - const channel = { name: 'testing channel' }; - loadChannel.mockImplementation(() => Promise.resolve(channel)); - await wrapper.vm.load(); - expect(document.title).toContain(channel.name); + await waitFor(() => { + expect(screen.getByText(testChannel.name)).toBeInTheDocument(); }); + + // Analytics is mocked and tracked in the component + expect(screen.getByText(testChannel.name)).toBeInTheDocument(); }); }); From 126801a83231804f7c91ff2533c5fb4c41bd5cdd Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Thu, 30 Oct 2025 08:45:55 +0530 Subject: [PATCH 4/9] Refactor StudioImmersiveModal and ChannelDetailsModal for improved structure and styling --- .../shared/views/StudioImmersiveModal.vue | 138 +++++------------- .../views/channel/ChannelDetailsModal.vue | 4 + .../__tests__/channelDetailsModal.spec.js | 34 ++++- 3 files changed, 66 insertions(+), 110 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue index 7dc9d8e989..2c50197834 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue @@ -2,47 +2,40 @@
-
- @@ -52,11 +45,13 @@ import { mapState } from 'vuex'; import StudioOfflineAlert from './StudioOfflineAlert'; + import StudioPage from './StudioPage'; export default { name: 'StudioImmersiveModal', components: { StudioOfflineAlert, + StudioPage, }, props: { value: { @@ -95,24 +90,6 @@ zIndex: 17, }; }, - titleStyle() { - return { - color: this.dark ? this.$themeTokens.textInverted : this.$themeTokens.text, - fontSize: '20px', - fontWeight: '500', - marginLeft: '16px', - marginRight: '16px', - }; - }, - contentStyle() { - const topOffset = this.offline ? 112 : 64; - return { - marginTop: `${topOffset}px`, - height: `calc(100vh - ${topOffset}px)`, - overflowY: 'auto', - backgroundColor: this.$themeTokens.surface, - }; - }, }, watch: { value(val) { @@ -124,12 +101,6 @@ } }, }, - mounted() { - this.hideHTMLScroll(this.value); - if (this.value) { - document.addEventListener('keydown', this.handleKeyDown); - } - }, beforeDestroy() { this.hideHTMLScroll(false); document.removeEventListener('keydown', this.handleKeyDown); @@ -140,10 +111,6 @@ ? 'overflow-y: hidden !important;' : 'overflow-y: auto !important'; }, - handleBackdropClick() { - // Don't close on backdrop click (persistent modal) - // This matches the behavior of FullscreenModal - }, handleKeyDown(event) { if (event.key === 'Escape') { this.$emit('input', false); @@ -160,25 +127,6 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue index e3581cafaf..ea652809fe 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue @@ -188,6 +188,10 @@ margin-bottom: 24px; } + [dir='rtl'] .download-button-container { + justify-content: flex-start; + } + @media (max-width: 600px) { .download-button-container { justify-content: center; diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js index dbab11ad33..5084d23685 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/channel/__tests__/channelDetailsModal.spec.js @@ -172,16 +172,19 @@ describe('ChannelDetailsModal', () => { it('pressing ESC key should close the modal', async () => { const user = userEvent.setup(); - renderComponent(); + const { container } = renderComponent(); await waitFor(() => { expect(screen.getByText(testChannel.name)).toBeInTheDocument(); }); + expect(container.querySelector('.studio-immersive-modal')).toBeInTheDocument(); + await user.keyboard('{Escape}'); - // Modal should remain rendered but dialog value changes - expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + await waitFor(() => { + expect(container.querySelector('.studio-immersive-modal')).not.toBeInTheDocument(); + }); }); it('should display download button after loading', async () => { @@ -193,15 +196,35 @@ describe('ChannelDetailsModal', () => { }); it('should display download button with dropdown functionality', async () => { + const user = userEvent.setup(); renderComponent(); await waitFor(() => { expect(screen.getByText('Download channel summary')).toBeInTheDocument(); }); - // Verify button is clickable - const downloadButton = screen.getByText('Download channel summary'); + const downloadButton = screen.getByRole('button', { name: /download channel summary/i }); expect(downloadButton).toBeInTheDocument(); + + await user.click(downloadButton); + + expect(await screen.findByText('Download PDF')).toBeInTheDocument(); + expect(await screen.findByText('Download CSV')).toBeInTheDocument(); + }); + + it('should call generateChannelsCSV when CSV option is selected', async () => { + const user = userEvent.setup(); + renderComponent(); + + await waitFor(() => { + expect(screen.getByText(testChannel.name)).toBeInTheDocument(); + }); + + const downloadButton = screen.getByRole('button', { name: /download channel summary/i }); + await user.click(downloadButton); + + const csvOption = await screen.findByText('Download CSV'); + await user.click(csvOption); }); it('should display details panel after loading', async () => { @@ -228,7 +251,6 @@ describe('ChannelDetailsModal', () => { expect(screen.getByText(testChannel.name)).toBeInTheDocument(); }); - // Analytics is mocked and tracked in the component expect(screen.getByText(testChannel.name)).toBeInTheDocument(); }); }); From 9dc051a712565c1426b5bbc221578a6d5663f901 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Thu, 30 Oct 2025 11:52:01 +0530 Subject: [PATCH 5/9] Enhance toolbar title styling in StudioImmersiveModal for improved visibility --- .../frontend/shared/views/StudioImmersiveModal.vue | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue index 2c50197834..806d028477 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue @@ -20,7 +20,7 @@ @@ -145,4 +145,14 @@ overflow-y: auto; } + .toolbar-title { + display: block; + margin-inline-start: 16px; + margin-inline-end: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: calc(100% - 80px); + } + From 3ced5cfdfd12040b2ca07a95903874a30729f760 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Mon, 3 Nov 2025 08:37:00 +0530 Subject: [PATCH 6/9] Refactor StudioImmersiveModal and StudioPage for improved styling and structure; enhance ChannelDetailsModal tests for better clarity and functionality --- .../shared/views/StudioImmersiveModal.vue | 45 +++------ .../frontend/shared/views/StudioPage.vue | 19 +++- .../__tests__/channelDetailsModal.spec.js | 92 ++++++++----------- 3 files changed, 63 insertions(+), 93 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue index 806d028477..8f2f73b815 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue @@ -6,7 +6,7 @@ > + + @@ -76,19 +78,13 @@ ...mapState({ offline: state => !state.connection.online, }), - toolbarStyle() { - const backgroundColor = - this.color === 'appBarDark' - ? this.$themePalette.grey.v_900 - : this.$themeTokens[this.color] || this.color; - return { - backgroundColor, - position: 'fixed', - top: 0, - left: 0, - right: 0, - zIndex: 17, - }; + toolbarBackgroundColor() { + return this.color === 'appBarDark' + ? this.$themePalette.grey.v_900 + : this.$themeTokens[this.color] || this.color; + }, + contentMarginTop() { + return this.offline ? 112 : 64; }, }, watch: { @@ -134,25 +130,8 @@ bottom: 0; left: 0; z-index: 17; - display: flex; - flex-direction: column; background-color: white; - } - - .modal-content { - flex: 1; - width: 100%; overflow-y: auto; } - .toolbar-title { - display: block; - margin-inline-start: 16px; - margin-inline-end: 16px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: calc(100% - 80px); - } - diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue b/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue index e473c66c39..0583036188 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue @@ -1,6 +1,9 @@ @@ -134,4 +134,12 @@ overflow-y: auto; } + .toolbar-title { + display: block; + margin-inline-start: 16px; + margin-inline-end: 16px; + white-space: nowrap; + max-width: calc(100% - 80px); + } + From 5437d8ac67091bf2351b529d16ca5c33163cae23 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Mon, 3 Nov 2025 14:50:59 +0530 Subject: [PATCH 8/9] Remove margin auto from innerStyle in StudioPage for improved layout consistency --- .../contentcuration/frontend/shared/views/StudioPage.vue | 1 - 1 file changed, 1 deletion(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue b/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue index 0583036188..07be418fd1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue @@ -32,7 +32,6 @@ paddingRight: `${paddingX.value}px`, paddingTop: `${paddingTop.value}px`, maxWidth: windowIsLarge.value ? '1000px' : '100%', - margin: '0 auto', })); const outerStyle = computed(() => { From 5a6dad362bd11005ecc25361bb14723077cbf490 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 09:23:59 +0000 Subject: [PATCH 9/9] [pre-commit.ci lite] apply automatic fixes --- .../frontend/shared/views/StudioImmersiveModal.vue | 12 ++++++------ .../frontend/shared/views/StudioPage.vue | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue index 845b2a6b77..6c959b4e03 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue @@ -29,9 +29,9 @@ - + - + diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue b/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue index 07be418fd1..eba2dae12f 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioPage.vue @@ -1,6 +1,6 @@