From b4b7827b5c58a3fde34766a2044c1f5893f99757 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Fri, 7 Nov 2025 15:24:34 +0530 Subject: [PATCH 01/15] Add StudioDetailsRow component for displaying studio details --- .../shared/views/details/StudioDetailsRow.vue | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue new file mode 100644 index 0000000000..d0a8f948e2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue @@ -0,0 +1,117 @@ + + + + + + + From 13c795b2dc42966e903cc3bcea1073cf0b4835a7 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Fri, 7 Nov 2025 15:25:06 +0530 Subject: [PATCH 02/15] Add StudioDetailsPanel component for displaying detailed studio information --- .../views/details/StudioDetailsPanel.vue | 760 ++++++++++++++++++ 1 file changed, 760 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue diff --git a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue new file mode 100644 index 0000000000..9573035824 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue @@ -0,0 +1,760 @@ + + + + + + + From 8cc6dc6082ba849369c5d724537e9abf5ee5c47f Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Fri, 7 Nov 2025 15:25:47 +0530 Subject: [PATCH 03/15] Replace DetailsPanel with StudioDetailsPanel in ChannelDetailsModal --- .../shared/views/channel/ChannelDetailsModal.vue | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue index 65c886f2c7..d95d345cc4 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue @@ -44,7 +44,7 @@ - import { mapActions, mapGetters } from 'vuex'; - import { channelExportMixin } from './mixins'; - import DetailsPanel from 'shared/views/details/DetailsPanel.vue'; import { routerMixin } from 'shared/mixins'; - import LoadingText from 'shared/views/LoadingText'; + import { channelExportMixin } from './mixins'; import FullscreenModal from 'shared/views/FullscreenModal'; + import LoadingText from 'shared/views/LoadingText'; + import StudioDetailsPanel from 'shared/views/details/StudioDetailsPanel.vue'; export default { name: 'ChannelDetailsModal', components: { - DetailsPanel, + StudioDetailsPanel, LoadingText, FullscreenModal, }, From bf3910421a00750d145722684c61c0130e95b5f7 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Fri, 7 Nov 2025 15:26:39 +0530 Subject: [PATCH 04/15] Add unit tests for StudioDetailsRow component --- .../views/__tests__/StudioDetailsRow.spec.js | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js new file mode 100644 index 0000000000..0507d4842e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js @@ -0,0 +1,150 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import StudioDetailsRow from '../details/StudioDetailsRow.vue'; + +const router = new VueRouter({ + routes: [], +}); + +const mockHelpTooltip = { + name: 'HelpTooltip', + props: ['text', 'tooltipId'], + template: '{{ text }}', +}; + +describe('StudioDetailsRow', () => { + it('renders label and text value', () => { + render(StudioDetailsRow, { + router, + props: { + label: 'Channel size', + text: '1.5 GB', + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + expect(screen.getByText('Channel size')).toBeInTheDocument(); + expect(screen.getByText('1.5 GB')).toBeInTheDocument(); + }); + + it('renders slot content instead of text prop', () => { + render(StudioDetailsRow, { + router, + props: { + label: 'Description', + }, + slots: { + default: '
Custom Content
', + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Custom Content')).toBeInTheDocument(); + }); + + it('displays HelpTooltip when definition is provided', () => { + render(StudioDetailsRow, { + router, + props: { + label: 'Resources for coaches', + text: '5', + definition: 'Resources for coaches are only visible to coaches in Kolibri', + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + expect(screen.getByTestId('help-tooltip')).toBeInTheDocument(); + expect(screen.getByText('Resources for coaches are only visible to coaches in Kolibri')) + .toBeInTheDocument(); + }); + + it('does not display HelpTooltip when definition is not provided', () => { + render(StudioDetailsRow, { + router, + props: { + label: 'Created on', + text: 'October 11, 2025', + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + expect(screen.queryByTestId('help-tooltip')).not.toBeInTheDocument(); + }); + + it('applies notranslate class to value when prop is true', () => { + const { container } = render(StudioDetailsRow, { + router, + props: { + label: 'Channel name', + text: 'User Generated Name', + notranslate: true, + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + const valueColumn = container.querySelector('.value-column'); + expect(valueColumn).toHaveClass('notranslate'); + }); + + it('does not apply notranslate class to value when prop is false', () => { + const { container } = render(StudioDetailsRow, { + router, + props: { + label: 'Channel size', + text: '1.5 GB', + notranslate: false, + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + const valueColumn = container.querySelector('.value-column'); + expect(valueColumn).not.toHaveClass('notranslate'); + }); + + it('has correct CSS classes for layout', () => { + const { container } = render(StudioDetailsRow, { + router, + props: { + label: 'Test Label', + text: 'Test Value', + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + expect(container.querySelector('.studio-details-row')).toBeTruthy(); + expect(container.querySelector('.label-column')).toBeTruthy(); + expect(container.querySelector('.value-column')).toBeTruthy(); + }); + + it('renders empty text prop correctly', () => { + const { container } = render(StudioDetailsRow, { + router, + props: { + label: 'Empty Field', + text: '', + }, + stubs: { + HelpTooltip: mockHelpTooltip, + }, + }); + + expect(screen.getByText('Empty Field')).toBeInTheDocument(); + const valueColumn = container.querySelector('.value-column'); + expect(valueColumn).toHaveTextContent(''); + }); +}); From fcfa1dbf71909170b9d41a3107a36ae0cdcebfa5 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Fri, 7 Nov 2025 15:27:12 +0530 Subject: [PATCH 05/15] Add tests for StudioDetailsPanel component --- .../__tests__/StudioDetailsPanel.spec.js | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js new file mode 100644 index 0000000000..8ea8aa281e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js @@ -0,0 +1,130 @@ +import { render } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import StudioDetailsPanel from '../details/StudioDetailsPanel.vue'; + +const router = new VueRouter({ + routes: [], +}); + +const mockChannelDetails = { + name: 'Test Channel', + description: 'Test channel description', + thumbnail_url: null, + published: true, + version: 1, + primary_token: 'test-token', + language: 'en', + resource_count: 10, + kind_count: [], +}; + +describe('StudioDetailsPanel', () => { + it('renders without crashing with minimal props', () => { + const { container } = render(StudioDetailsPanel, { + router, + props: { + details: {}, + isChannel: false, + loading: false, + }, + mocks: { + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'Test Date'), + $tr: jest.fn(key => key), + }, + }); + + expect(container).toBeInTheDocument(); + }); + + it('displays loader when loading is true', () => { + const { container } = render(StudioDetailsPanel, { + router, + props: { + details: {}, + isChannel: true, + loading: true, + }, + mocks: { + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'Test Date'), + $tr: jest.fn(key => key), + }, + }); + + expect(container).toBeInTheDocument(); + }); + + it('renders channel information', () => { + const { container } = render(StudioDetailsPanel, { + router, + props: { + details: mockChannelDetails, + isChannel: true, + loading: false, + }, + mocks: { + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'Test Date'), + $tr: jest.fn(key => key), + }, + }); + + expect(container).toHaveTextContent('Test Channel'); + }); + + it('does not use VDataTable', () => { + const { container } = render(StudioDetailsPanel, { + router, + props: { + details: mockChannelDetails, + isChannel: true, + loading: false, + }, + mocks: { + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'Test Date'), + $tr: jest.fn(key => key), + }, + }); + + expect(container.querySelector('.v-datatable')).not.toBeInTheDocument(); + }); + + it('does not use VChip', () => { + const { container } = render(StudioDetailsPanel, { + router, + props: { + details: mockChannelDetails, + isChannel: true, + loading: false, + }, + mocks: { + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'Test Date'), + $tr: jest.fn(key => key), + }, + }); + + expect(container.querySelector('.v-chip')).not.toBeInTheDocument(); + }); + + it('does not use VLayout or VFlex', () => { + const { container } = render(StudioDetailsPanel, { + router, + props: { + details: mockChannelDetails, + isChannel: true, + loading: false, + }, + mocks: { + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'Test Date'), + $tr: jest.fn(key => key), + }, + }); + + expect(container.querySelector('.v-layout')).not.toBeInTheDocument(); + expect(container.querySelector('.v-flex')).not.toBeInTheDocument(); + }); +}); From 4d126f96d94761b80a0464918247893ce5f31511 Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Fri, 7 Nov 2025 16:17:55 +0530 Subject: [PATCH 06/15] Refactor StudioChip and StudioDetailsRow styles; update ChannelDetailsModal imports --- .../frontend/shared/views/StudioChip.vue | 127 ++++-------------- .../views/details/StudioDetailsPanel.vue | 5 +- .../shared/views/details/StudioDetailsRow.vue | 2 +- 3 files changed, 31 insertions(+), 103 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/StudioChip.vue b/contentcuration/contentcuration/frontend/shared/views/StudioChip.vue index 01d8778ddf..9e693c2814 100644 --- a/contentcuration/contentcuration/frontend/shared/views/StudioChip.vue +++ b/contentcuration/contentcuration/frontend/shared/views/StudioChip.vue @@ -1,82 +1,44 @@ - - - diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioChip.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioChip.spec.js deleted file mode 100644 index efc172b6a2..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioChip.spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen } from '@testing-library/vue'; -import userEvent from '@testing-library/user-event'; -import VueRouter from 'vue-router'; -import StudioChip from '../StudioChip.vue'; - -const mockRouter = new VueRouter(); - -const renderComponent = (props = {}) => { - const defaultProps = { - text: 'Test Chip', - close: false, - ...props, - }; - - return render(StudioChip, { - props: defaultProps, - routes: mockRouter, - }); -}; - -describe('StudioChip', () => { - test('renders with text prop', () => { - renderComponent({ text: 'Test Chip' }); - expect(screen.getByText('Test Chip')).toBeInTheDocument(); - }); - - test('renders close button when close prop is true', () => { - renderComponent({ close: true }); - expect(screen.getByRole('button', { name: 'Remove Test Chip' })).toBeInTheDocument(); - }); - - test('does not render close button when close prop is false', () => { - renderComponent({ close: false }); - expect(screen.queryByRole('button', { name: 'Remove Test Chip' })).not.toBeInTheDocument(); - }); - - test('emits close event when close button is clicked', async () => { - const user = userEvent.setup(); - const { emitted } = renderComponent({ close: true }); - - await user.click(screen.getByRole('button', { name: 'Remove Test Chip' })); - expect(emitted().close).toHaveLength(1); - }); -}); From 02dd1faaabd7b847565e3b9885036b1ad60a35fb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 04:41:45 +0000 Subject: [PATCH 09/15] [pre-commit.ci lite] apply automatic fixes --- .../administration/pages/Users/EmailUsersDialog.vue | 2 +- .../shared/views/__tests__/StudioDetailsRow.spec.js | 5 +++-- .../shared/views/channel/ChannelDetailsModal.vue | 2 +- .../shared/views/details/StudioDetailsPanel.vue | 12 ++++++------ .../shared/views/details/StudioDetailsRow.vue | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue b/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue index b01d127bf7..a14c96ff81 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Users/EmailUsersDialog.vue @@ -131,9 +131,9 @@ + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js index 0125abe4bd..ea365b82e3 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js @@ -153,8 +153,9 @@ describe('StudioDetailsPanel', () => { // When levels array is populated, the levelsHeading row should be rendered expect(wrapper.container).toHaveTextContent('levelsHeading'); // Levels are rendered through the component, verify the section exists - const levelRows = Array.from(wrapper.container.querySelectorAll('*')) - .filter(el => el.textContent?.includes('levelsHeading')); + const levelRows = Array.from(wrapper.container.querySelectorAll('*')).filter(el => + el.textContent?.includes('levelsHeading'), + ); expect(levelRows.length).toBeGreaterThan(0); }); @@ -162,8 +163,9 @@ describe('StudioDetailsPanel', () => { // When categories array is populated, the categoriesHeading row should be rendered expect(wrapper.container).toHaveTextContent('categoriesHeading'); // Categories are rendered through the component, verify the section exists - const categoryRows = Array.from(wrapper.container.querySelectorAll('*')) - .filter(el => el.textContent?.includes('categoriesHeading')); + const categoryRows = Array.from(wrapper.container.querySelectorAll('*')).filter(el => + el.textContent?.includes('categoriesHeading'), + ); expect(categoryRows.length).toBeGreaterThan(0); }); @@ -256,8 +258,9 @@ describe('StudioDetailsPanel', () => { it('should not show primary language when not set', () => { // Language row should not appear if language is not set const container = wrapper.container; - const languageRows = Array.from(container.querySelectorAll('*')) - .filter(el => el.textContent?.includes('primaryLanguageHeading')); + const languageRows = Array.from(container.querySelectorAll('*')).filter(el => + el.textContent?.includes('primaryLanguageHeading'), + ); expect(languageRows.length).toBe(0); }); From 253441885875296cd38a11e19ce90db06258678b Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Thu, 20 Nov 2025 11:07:43 +0530 Subject: [PATCH 12/15] Remove unnecessary comments from StudioDetailsPanel tests --- .../views/__tests__/StudioDetailsPanel.spec.js | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js index ea365b82e3..1795b51c83 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js @@ -150,9 +150,7 @@ describe('StudioDetailsPanel', () => { }); it('should render educational levels when present', () => { - // When levels array is populated, the levelsHeading row should be rendered expect(wrapper.container).toHaveTextContent('levelsHeading'); - // Levels are rendered through the component, verify the section exists const levelRows = Array.from(wrapper.container.querySelectorAll('*')).filter(el => el.textContent?.includes('levelsHeading'), ); @@ -160,9 +158,7 @@ describe('StudioDetailsPanel', () => { }); it('should render categories when present', () => { - // When categories array is populated, the categoriesHeading row should be rendered expect(wrapper.container).toHaveTextContent('categoriesHeading'); - // Categories are rendered through the component, verify the section exists const categoryRows = Array.from(wrapper.container.querySelectorAll('*')).filter(el => el.textContent?.includes('categoriesHeading'), ); @@ -242,8 +238,6 @@ describe('StudioDetailsPanel', () => { }); it('should show placeholder icon when thumbnail is missing', () => { - // Verify that the component renders without crashing - // and that a placeholder is shown instead of an image expect(wrapper.container).toBeInTheDocument(); }); @@ -256,7 +250,6 @@ describe('StudioDetailsPanel', () => { }); it('should not show primary language when not set', () => { - // Language row should not appear if language is not set const container = wrapper.container; const languageRows = Array.from(container.querySelectorAll('*')).filter(el => el.textContent?.includes('primaryLanguageHeading'), @@ -269,7 +262,6 @@ describe('StudioDetailsPanel', () => { }); it('should not display token row when token is not present', () => { - // Token row should not appear if primary_token is null expect(wrapper.container).not.toHaveTextContent('test-token'); }); }); @@ -324,18 +316,13 @@ describe('StudioDetailsPanel', () => { it('should render fields that have data and show placeholder for missing fields', () => { expect(wrapper.container).toHaveTextContent('Partial Channel'); expect(wrapper.container).toHaveTextContent('Some description'); - // Categories section is rendered when data present expect(wrapper.container).toHaveTextContent('categoriesHeading'); - // Levels should show placeholder since array is empty expect(wrapper.container).toHaveTextContent('---'); }); it('should render both data-present and data-absent states', () => { - // Has categories (heading should be present) expect(wrapper.container).toHaveTextContent('categoriesHeading'); - // Missing levels expect(wrapper.container).toHaveTextContent('levelsHeading'); - // Placeholder text for missing data expect(wrapper.container).toHaveTextContent('---'); }); }); From 2533dbab057cc55e5e497f561897ff965dba43dc Mon Sep 17 00:00:00 2001 From: Tushar Verma Date: Wed, 3 Dec 2025 16:43:20 +0530 Subject: [PATCH 13/15] Refactor StudioDetailsPanel and StudioDetailsRow components; improve placeholder handling and styling consistency --- .../__tests__/StudioDetailsPanel.spec.js | 439 ++++++------------ .../views/__tests__/StudioDetailsRow.spec.js | 270 ++--------- .../views/details/StudioDetailsPanel.vue | 27 +- .../shared/views/details/StudioDetailsRow.vue | 2 +- 4 files changed, 193 insertions(+), 545 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js index 1795b51c83..86240788da 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js @@ -1,329 +1,184 @@ -import { render } from '@testing-library/vue'; +import { render, screen } from '@testing-library/vue'; import VueRouter from 'vue-router'; import StudioDetailsPanel from '../details/StudioDetailsPanel.vue'; -const router = new VueRouter({ - routes: [], -}); - -const mockChannelDetails = { - name: 'Test Channel', - description: 'Test channel description', - thumbnail_url: null, - published: true, - version: 1, - primary_token: 'test-token', - language: 'en', - resource_count: 10, - kind_count: [], -}; - -describe('StudioDetailsPanel', () => { - it('renders without crashing with minimal props', () => { - const { container } = render(StudioDetailsPanel, { - router, - props: { - details: {}, - isChannel: false, - loading: false, - }, - mocks: { - $formatNumber: jest.fn(n => String(n)), - $formatDate: jest.fn(() => 'Test Date'), - $tr: jest.fn(key => key), - }, - }); - - expect(container).toBeInTheDocument(); - }); - - it('displays loader when loading is true', () => { - const { container } = render(StudioDetailsPanel, { - router, - props: { - details: {}, - isChannel: true, - loading: true, - }, - mocks: { - $formatNumber: jest.fn(n => String(n)), - $formatDate: jest.fn(() => 'Test Date'), - $tr: jest.fn(key => key), - }, - }); - - expect(container).toBeInTheDocument(); - }); - - it('renders channel information', () => { - const { container } = render(StudioDetailsPanel, { - router, - props: { - details: mockChannelDetails, - isChannel: true, - loading: false, - }, - mocks: { - $formatNumber: jest.fn(n => String(n)), - $formatDate: jest.fn(() => 'Test Date'), - $tr: jest.fn(key => key), - }, - }); - - expect(container).toHaveTextContent('Test Channel'); - }); - - describe('when channel has all data', () => { - const fullDataChannel = { - name: 'Complete Channel', - description: 'A fully populated channel with all fields', - thumbnail_url: 'https://example.com/thumb.jpg', - published: true, - version: 2, - primary_token: 'test-token-abc123', - language: 'en', - created: '2025-01-15T10:00:00Z', - last_update: '2025-01-20T15:30:00Z', - resource_count: 42, - resource_size: 1024000000, - kind_count: [ - { kind_id: 'video', count: 20 }, - { kind_id: 'document', count: 22 }, - ], - levels: ['Level 1', 'Level 2', 'Level 3'], - categories: ['Category A', 'Category B'], - includes: { coach_content: 1, exercises: 1 }, - tags: [{ tag_name: 'science' }, { tag_name: 'math' }], - languages: ['English', 'Spanish', 'French'], - accessible_languages: ['English'], - authors: ['Author One', 'Author Two'], - providers: ['Provider ABC'], - aggregators: ['Aggregator XYZ'], - licenses: ['CC_BY_SA_3_0'], - copyright_holders: ['Copyright Holder Inc'], - original_channels: [], - sample_nodes: [], +jest.mock('../../constants', () => ({ + ...jest.requireActual('../../constants'), + LevelsLookup: { + lower_primary: 'LOWER_PRIMARY', + upper_primary: 'UPPER_PRIMARY', + }, + CategoriesLookup: { + mathematics: 'MATHEMATICS', + sciences: 'SCIENCES', + }, +})); + +jest.mock('../../utils/metadataStringsTranslation', () => ({ + translateMetadataString: jest.fn(key => { + const translations = { + lowerPrimary: 'Lower Primary', + upperPrimary: 'Upper Primary', + mathematics: 'Mathematics', + sciences: 'Sciences', }; + return translations[key] || key; + }), +})); + +const router = new VueRouter({ routes: [] }); + +const translations = { + publishedHeading: 'Published on', + currentVersionHeading: 'Published version', + primaryLanguageHeading: 'Primary language', + creationHeading: 'Created on', + sizeHeading: 'Channel size', + resourceHeading: 'Total resources', + levelsHeading: 'Levels', + categoriesHeading: 'Categories', + authorsLabel: 'Authors', + tagsHeading: 'Common tags', + unpublishedText: 'Unpublished', +}; - let wrapper; +const createMocks = () => ({ + $formatNumber: jest.fn(n => String(n)), + $formatDate: jest.fn(() => 'January 15, 2025'), + $tr: jest.fn(key => translations[key] || key), + translateConstant: jest.fn(key => key), +}); - beforeEach(() => { - wrapper = render(StudioDetailsPanel, { +describe('StudioDetailsPanel', () => { + const fullChannel = { + name: 'Complete Channel', + description: 'A fully populated channel', + thumbnail_url: 'https://example.com/thumb.jpg', + published: true, + version: 2, + primary_token: 'abc12345', + language: 'en', + created: '2025-01-15T10:00:00Z', + last_published: '2025-01-20T15:30:00Z', + resource_count: 42, + resource_size: 1024000000, + kind_count: [], + levels: ['lower_primary', 'upper_primary'], + categories: ['mathematics', 'sciences'], + includes: { coach_content: 1, exercises: 1 }, + tags: [{ tag_name: 'science' }, { tag_name: 'math' }], + languages: [], + accessible_languages: [], + authors: ['Author One', 'Author Two'], + providers: [], + aggregators: [], + licenses: [], + copyright_holders: [], + original_channels: [], + sample_nodes: [], + }; + + const minimalChannel = { + name: 'Minimal Channel', + description: '', + thumbnail_url: null, + published: false, + version: null, + primary_token: null, + language: null, + created: null, + last_published: null, + resource_count: 0, + resource_size: 0, + kind_count: [], + levels: [], + categories: [], + includes: { coach_content: 0, exercises: 0 }, + tags: [], + languages: [], + accessible_languages: [], + authors: [], + providers: [], + aggregators: [], + licenses: [], + copyright_holders: [], + original_channels: [], + sample_nodes: [], + }; + + describe('basic rendering', () => { + it('renders channel header with name and description', () => { + render(StudioDetailsPanel, { router, - props: { - details: fullDataChannel, - isChannel: true, - loading: false, - }, - mocks: { - $formatNumber: jest.fn(n => String(n)), - $formatDate: jest.fn(() => 'Test Date'), - $tr: jest.fn(key => key), - }, + props: { details: fullChannel, isChannel: true, loading: false }, + mocks: createMocks(), }); - }); - - it('should render channel name', () => { - expect(wrapper.container).toHaveTextContent('Complete Channel'); - }); - it('should render channel description', () => { - expect(wrapper.container).toHaveTextContent('A fully populated channel with all fields'); + expect(screen.getByText('Complete Channel')).toBeInTheDocument(); + expect(screen.getByText('A fully populated channel')).toBeInTheDocument(); }); - it('should render published status', () => { - expect(wrapper.container).toHaveTextContent('publishedHeading'); - }); - - it('should render version information', () => { - expect(wrapper.container).toHaveTextContent('currentVersionHeading'); - expect(wrapper.container).toHaveTextContent('2'); - }); - - it('should render primary language', () => { - expect(wrapper.container).toHaveTextContent('primaryLanguageHeading'); - }); - - it('should render resource count', () => { - expect(wrapper.container).toHaveTextContent('resourceHeading'); - expect(wrapper.container).toHaveTextContent('42'); - }); - - it('should render educational levels when present', () => { - expect(wrapper.container).toHaveTextContent('levelsHeading'); - const levelRows = Array.from(wrapper.container.querySelectorAll('*')).filter(el => - el.textContent?.includes('levelsHeading'), - ); - expect(levelRows.length).toBeGreaterThan(0); - }); - - it('should render categories when present', () => { - expect(wrapper.container).toHaveTextContent('categoriesHeading'); - const categoryRows = Array.from(wrapper.container.querySelectorAll('*')).filter(el => - el.textContent?.includes('categoriesHeading'), - ); - expect(categoryRows.length).toBeGreaterThan(0); - }); - - it('should render creation date', () => { - expect(wrapper.container).toHaveTextContent('creationHeading'); - }); - - it('should render channel size', () => { - expect(wrapper.container).toHaveTextContent('sizeHeading'); - }); - - it('should render licenses when present', () => { - expect(wrapper.container).toHaveTextContent('licensesLabel'); - }); - - it('should render authors when present', () => { - expect(wrapper.container).toHaveTextContent('authorsLabel'); - }); + it('shows placeholder when thumbnail is missing', () => { + render(StudioDetailsPanel, { + router, + props: { details: minimalChannel, isChannel: true, loading: false }, + mocks: createMocks(), + }); - it('should render tags when present', () => { - expect(wrapper.container).toHaveTextContent('tagsHeading'); + expect(screen.getByTestId('placeholder-content')).toBeInTheDocument(); }); }); - describe('when channel has missing data', () => { - const minimalChannel = { - name: 'Minimal Channel', - description: '', - thumbnail_url: null, - published: false, - version: null, - primary_token: null, - language: null, - created: null, - last_update: null, - resource_count: 0, - resource_size: 0, - kind_count: [], - levels: [], - categories: [], - includes: { coach_content: 0, exercises: 0 }, - tags: [], - languages: [], - accessible_languages: [], - authors: [], - providers: [], - aggregators: [], - licenses: [], - copyright_holders: [], - original_channels: [], - sample_nodes: [], - }; - - let wrapper; - + describe('published channel with full data', () => { beforeEach(() => { - wrapper = render(StudioDetailsPanel, { + render(StudioDetailsPanel, { router, - props: { - details: minimalChannel, - isChannel: true, - loading: false, - }, - mocks: { - $formatNumber: jest.fn(n => String(n)), - $formatDate: jest.fn(() => 'Test Date'), - $tr: jest.fn(key => key), - }, + props: { details: fullChannel, isChannel: true, loading: false }, + mocks: createMocks(), }); }); - it('should render channel name even when minimal', () => { - expect(wrapper.container).toHaveTextContent('Minimal Channel'); - }); - - it('should show placeholder icon when thumbnail is missing', () => { - expect(wrapper.container).toBeInTheDocument(); - }); - - it('should show default text when levels are missing', () => { - expect(wrapper.container).toHaveTextContent('---'); - }); - - it('should show default text when categories are missing', () => { - expect(wrapper.container).toHaveTextContent('---'); + it('displays published status and version', () => { + expect(screen.getByText('Published on')).toBeInTheDocument(); + expect(screen.getByText('Published version')).toBeInTheDocument(); }); - it('should not show primary language when not set', () => { - const container = wrapper.container; - const languageRows = Array.from(container.querySelectorAll('*')).filter(el => - el.textContent?.includes('primaryLanguageHeading'), - ); - expect(languageRows.length).toBe(0); + it('displays translated levels and categories', () => { + expect(screen.getByText('Lower Primary')).toBeInTheDocument(); + expect(screen.getByText('Upper Primary')).toBeInTheDocument(); + expect(screen.getByText('Mathematics')).toBeInTheDocument(); + expect(screen.getByText('Sciences')).toBeInTheDocument(); }); - it('should render unpublished status when published is false', () => { - expect(wrapper.container).toHaveTextContent('unpublishedText'); - }); - - it('should not display token row when token is not present', () => { - expect(wrapper.container).not.toHaveTextContent('test-token'); + it('displays resource count and metadata', () => { + expect(screen.getByText('42')).toBeInTheDocument(); + expect(screen.getByText('Author One')).toBeInTheDocument(); + expect(screen.getByText('Author Two')).toBeInTheDocument(); + expect(screen.getByText('science')).toBeInTheDocument(); + expect(screen.getByText('math')).toBeInTheDocument(); }); }); - describe('when channel has partial data', () => { - const partialChannel = { - name: 'Partial Channel', - description: 'Some description', - thumbnail_url: null, - published: true, - version: 1, - primary_token: 'partial-token-xyz', - language: 'es', - created: '2025-01-10T10:00:00Z', - last_update: '2025-01-18T10:00:00Z', - resource_count: 15, - resource_size: 512000000, - kind_count: [{ kind_id: 'video', count: 15 }], - levels: [], - categories: ['Category C'], - includes: { coach_content: 0, exercises: 1 }, - tags: [], - languages: [], - accessible_languages: [], - authors: [], - providers: [], - aggregators: [], - licenses: [], - copyright_holders: [], - original_channels: [], - sample_nodes: [], - }; - - let wrapper; - + describe('unpublished channel with missing data', () => { beforeEach(() => { - wrapper = render(StudioDetailsPanel, { + render(StudioDetailsPanel, { router, - props: { - details: partialChannel, - isChannel: true, - loading: false, - }, - mocks: { - $formatNumber: jest.fn(n => String(n)), - $formatDate: jest.fn(() => 'Test Date'), - $tr: jest.fn(key => key), - }, + props: { details: minimalChannel, isChannel: true, loading: false }, + mocks: createMocks(), }); }); - it('should render fields that have data and show placeholder for missing fields', () => { - expect(wrapper.container).toHaveTextContent('Partial Channel'); - expect(wrapper.container).toHaveTextContent('Some description'); - expect(wrapper.container).toHaveTextContent('categoriesHeading'); - expect(wrapper.container).toHaveTextContent('---'); + it('displays unpublished status', () => { + expect(screen.getByText('Unpublished')).toBeInTheDocument(); + }); + + it('shows placeholder text for empty fields', () => { + const placeholders = screen.getAllByText('---'); + expect(placeholders.length).toBeGreaterThan(0); }); - it('should render both data-present and data-absent states', () => { - expect(wrapper.container).toHaveTextContent('categoriesHeading'); - expect(wrapper.container).toHaveTextContent('levelsHeading'); - expect(wrapper.container).toHaveTextContent('---'); + it('hides conditional fields when data is missing', () => { + expect(screen.queryByText('Primary language')).not.toBeInTheDocument(); }); }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js index f94363f482..5bad667ed1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js @@ -2,288 +2,76 @@ import { render, screen } from '@testing-library/vue'; import VueRouter from 'vue-router'; import StudioDetailsRow from '../details/StudioDetailsRow.vue'; -const router = new VueRouter({ - routes: [], -}); +const router = new VueRouter({ routes: [] }); -const mockHelpTooltip = { +const HelpTooltipStub = { name: 'HelpTooltip', props: ['text', 'tooltipId'], template: '{{ text }}', }; describe('StudioDetailsRow', () => { - it('renders label and text value', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Channel size', - text: '1.5 GB', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Channel size')).toBeInTheDocument(); - expect(screen.getByText('1.5 GB')).toBeInTheDocument(); - }); - - it('renders slot content instead of text prop', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Description', - }, - slots: { - default: '
Custom Content
', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Description')).toBeInTheDocument(); - expect(screen.getByText('Custom Content')).toBeInTheDocument(); - }); - - it('displays HelpTooltip when definition is provided', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Resources for coaches', - text: '5', - definition: 'Resources for coaches are only visible to coaches in Kolibri', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByTestId('help-tooltip')).toBeInTheDocument(); - expect( - screen.getByText('Resources for coaches are only visible to coaches in Kolibri'), - ).toBeInTheDocument(); - }); - - it('does not display HelpTooltip when definition is not provided', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Created on', - text: 'October 11, 2025', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.queryByTestId('help-tooltip')).not.toBeInTheDocument(); - }); - - it('renders empty text prop correctly', () => { - const { container } = render(StudioDetailsRow, { - router, - props: { - label: 'Empty Field', - text: '', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Empty Field')).toBeInTheDocument(); - const valueColumn = container.querySelector('.value-column'); - expect(valueColumn).toHaveTextContent(''); - }); - - describe('edge cases with various empty/falsy values', () => { - it('renders null value correctly', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Null Field', - text: null, - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Null Field')).toBeInTheDocument(); - }); - - it('renders undefined value correctly', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Undefined Field', - text: undefined, - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Undefined Field')).toBeInTheDocument(); - }); - - it('renders zero value correctly', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Zero Count', - text: '0', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Zero Count')).toBeInTheDocument(); - expect(screen.getByText('0')).toBeInTheDocument(); - }); - - it('renders false boolean value correctly', () => { + describe('basic rendering', () => { + it('renders label and text value', () => { render(StudioDetailsRow, { router, - props: { - label: 'Boolean Field', - text: 'false', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, + props: { label: 'Channel size', text: '1.5 GB' }, + stubs: { HelpTooltip: HelpTooltipStub }, }); - expect(screen.getByText('Boolean Field')).toBeInTheDocument(); - expect(screen.getByText('false')).toBeInTheDocument(); + expect(screen.getByText('Channel size')).toBeInTheDocument(); + expect(screen.getByText('1.5 GB')).toBeInTheDocument(); }); - it('renders whitespace-only text correctly', () => { + it('renders slot content instead of text prop', () => { render(StudioDetailsRow, { router, - props: { - label: 'Whitespace Field', - text: ' ', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, + props: { label: 'Authors' }, + slots: { default: '
Author One, Author Two
' }, + stubs: { HelpTooltip: HelpTooltipStub }, }); - expect(screen.getByText('Whitespace Field')).toBeInTheDocument(); + expect(screen.getByText('Authors')).toBeInTheDocument(); + expect(screen.getByText('Author One, Author Two')).toBeInTheDocument(); }); - it('renders very long text value correctly', () => { - const longText = 'A'.repeat(500); + it('prioritizes slot content over text prop', () => { render(StudioDetailsRow, { router, - props: { - label: 'Long Text Field', - text: longText, - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, + props: { label: 'Field', text: 'Text Value' }, + slots: { default: 'Slot Value' }, + stubs: { HelpTooltip: HelpTooltipStub }, }); - expect(screen.getByText('Long Text Field')).toBeInTheDocument(); - expect(screen.getByText(longText)).toBeInTheDocument(); + expect(screen.getByText('Slot Value')).toBeInTheDocument(); + expect(screen.queryByText('Text Value')).not.toBeInTheDocument(); }); }); describe('tooltip behavior', () => { - it('displays tooltip with correct content when definition is provided', () => { + it('displays HelpTooltip when definition is provided', () => { render(StudioDetailsRow, { router, props: { - label: 'Test Label', - text: 'Test Value', - definition: 'This is a detailed explanation of the field', - }, - stubs: { - HelpTooltip: mockHelpTooltip, + label: 'Resources for coaches', + text: '5', + definition: 'Resources only visible to coaches', }, + stubs: { HelpTooltip: HelpTooltipStub }, }); expect(screen.getByTestId('help-tooltip')).toBeInTheDocument(); - expect(screen.getByText('This is a detailed explanation of the field')).toBeInTheDocument(); + expect(screen.getByText('Resources only visible to coaches')).toBeInTheDocument(); }); - it('does not display tooltip when definition is empty string', () => { + it('does not display HelpTooltip when definition is not provided', () => { render(StudioDetailsRow, { router, - props: { - label: 'Test Label', - text: 'Test Value', - definition: '', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.queryByTestId('help-tooltip')).not.toBeInTheDocument(); - }); - - it('does not display tooltip when definition is null', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Test Label', - text: 'Test Value', - definition: null, - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, + props: { label: 'Created on', text: 'October 11, 2025' }, + stubs: { HelpTooltip: HelpTooltipStub }, }); expect(screen.queryByTestId('help-tooltip')).not.toBeInTheDocument(); }); }); - - describe('slot content rendering', () => { - it('prioritizes slot content over text prop when both provided', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Mixed Content', - text: 'Should not see this', - }, - slots: { - default: '
Slot content shown instead
', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Mixed Content')).toBeInTheDocument(); - expect(screen.getByText('Slot content shown instead')).toBeInTheDocument(); - expect(screen.queryByText('Should not see this')).not.toBeInTheDocument(); - }); - - it('renders multiple elements in slot correctly', () => { - render(StudioDetailsRow, { - router, - props: { - label: 'Complex Slot', - }, - slots: { - default: '
First element
Second element
Third element
', - }, - stubs: { - HelpTooltip: mockHelpTooltip, - }, - }); - - expect(screen.getByText('Complex Slot')).toBeInTheDocument(); - expect(screen.getByText('First element')).toBeInTheDocument(); - expect(screen.getByText('Second element')).toBeInTheDocument(); - expect(screen.getByText('Third element')).toBeInTheDocument(); - }); - }); }); diff --git a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue index 9a56e3e39b..721b501a2a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsPanel.vue @@ -15,10 +15,14 @@ aspectRatio="16:9" > @@ -174,9 +178,8 @@ v-for="tag in sortedTags" v-else-if="!printing" :key="tag.tag_name" - :notranslate="true" > - {{ tag.tag_name }} + {{ tag.tag_name }} {{ tagPrintable }} @@ -351,10 +354,13 @@ aspectRatio="16:9" > @@ -628,7 +634,6 @@ justify-content: center; width: 100%; height: 100%; - background-color: var(--v-grey-lighten4); } .resource-list { @@ -681,7 +686,7 @@ .source-thumbnail { flex-shrink: 0; width: 150px; - border: 1px solid var(--v-grey-lighten3, #e0e0e0); + border: 1px solid #e0e0e0; } .channel-name { @@ -693,7 +698,7 @@ .channel-link { margin: 0 8px; font-weight: bold; - color: var(--v-primary-base, #1976d2); + color: #1976d2; text-decoration: none; span { @@ -720,7 +725,7 @@ font-size: 14px; font-weight: bold; line-height: 20px; - color: var(--v-grey-darken3); + color: #424242; } .sample-nodes { @@ -744,7 +749,7 @@ flex-direction: column; height: 100%; overflow: hidden; - border: 1px solid var(--v-grey-lighten3, #e0e0e0); + border: 1px solid #e0e0e0; border-radius: 4px; > div:last-child { diff --git a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue index 5ed7410da4..eba70681c0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue @@ -7,6 +7,7 @@