diff --git a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue index 470a807b9e..b50458234a 100644 --- a/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue +++ b/contentcuration/contentcuration/frontend/administration/pages/Channels/ChannelDetails.vue @@ -59,7 +59,7 @@ flat class="px-5" > - import { mapActions, mapGetters } from 'vuex'; + import StudioChip from 'shared/views/StudioChip'; import ExpandableList from 'shared/views/ExpandableList'; import { generateFormMixin } from 'shared/mixins'; - import StudioChip from 'shared/views/StudioChip'; const formMixin = generateFormMixin({ subject: { required: true }, diff --git a/contentcuration/contentcuration/frontend/shared/utils/icons.js b/contentcuration/contentcuration/frontend/shared/utils/icons.js new file mode 100644 index 0000000000..cd07955e3c --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/utils/icons.js @@ -0,0 +1,22 @@ +import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; + +const EMPTY = '_empty'; +const CONTENT_KIND_ICONS = { + [ContentKindsNames.TOPIC]: 'topic', + [ContentKindsNames.TOPIC + EMPTY]: 'emptyTopic', + [ContentKindsNames.VIDEO]: 'video', + [ContentKindsNames.AUDIO]: 'audio', + [ContentKindsNames.SLIDESHOW]: 'slideshow', + [ContentKindsNames.EXERCISE]: 'exercise', + [ContentKindsNames.DOCUMENT]: 'document', + [ContentKindsNames.HTML5]: 'html5', + [ContentKindsNames.ZIM]: 'html5', +}; + +export function getContentKindIcon(kind, isEmpty = false) { + const icon = (isEmpty ? [kind + EMPTY] : []).concat([kind]).find(k => k in CONTENT_KIND_ICONS); + if (!icon) { + throw new Error(`Icon not found for content kind: ${kind}`); + } + return CONTENT_KIND_ICONS[icon]; +} 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..9c03e95b26 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsPanel.spec.js @@ -0,0 +1,118 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import ContentLevels from 'kolibri-constants/labels/Levels'; +import Categories from 'kolibri-constants/labels/Subjects'; +import StudioDetailsPanel from '../details/StudioDetailsPanel.vue'; + +const renderComponent = (props = {}) => { + return render(StudioDetailsPanel, { + props, + routes: new VueRouter(), + }); +}; + +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: [ContentLevels.LOWER_PRIMARY, ContentLevels.UPPER_PRIMARY], + categories: [Categories.MATHEMATICS, Categories.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: [], + }; + + it('renders channel header with name and description', () => { + renderComponent({ details: fullChannel, loading: false }); + + expect(screen.getByText('Complete Channel')).toBeInTheDocument(); + expect(screen.getByText('A fully populated channel')).toBeInTheDocument(); + }); + + describe('published channel with full data', () => { + beforeEach(() => { + renderComponent({ details: fullChannel, loading: false }); + }); + + it('displays published status and version', () => { + expect(screen.getByText('Published on')).toBeInTheDocument(); + expect(screen.getByText('Published version')).toBeInTheDocument(); + }); + + 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('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('unpublished channel with missing data', () => { + beforeEach(() => { + renderComponent({ details: minimalChannel, loading: false }); + }); + + 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); + }); + }); +}); 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..abb7b2e87f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/__tests__/StudioDetailsRow.spec.js @@ -0,0 +1,40 @@ +import { render, screen } from '@testing-library/vue'; +import VueRouter from 'vue-router'; +import StudioDetailsRow from '../details/StudioDetailsRow.vue'; + +const renderComponent = (props = {}, slots = {}) => { + return render(StudioDetailsRow, { + props, + slots, + routes: new VueRouter(), + }); +}; + +describe('StudioDetailsRow', () => { + it('renders label and text value', () => { + renderComponent({ + label: 'Channel size', + text: '1.5 GB', + }); + + expect(screen.getByText('Channel size')).toBeInTheDocument(); + expect(screen.getByText('1.5 GB')).toBeInTheDocument(); + }); + + it('renders slot content', () => { + renderComponent({ label: 'Authors' }, { default: '
Author One, Author Two
' }); + + expect(screen.getByText('Authors')).toBeInTheDocument(); + expect(screen.getByText('Author One, Author Two')).toBeInTheDocument(); + }); + + it('displays tooltip when definition is provided', () => { + renderComponent({ + label: 'Resources for coaches', + text: '5', + definition: 'Resources only visible to coaches', + }); + + expect(screen.getByLabelText('Resources only visible to coaches')).toBeInTheDocument(); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelCatalogPrint.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelCatalogPrint.vue index df346fb862..dd7d25e4b8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelCatalogPrint.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelCatalogPrint.vue @@ -8,7 +8,7 @@ :channelList="channelList" :style="pageStyle" /> - - import DetailsPanel from '../details/DetailsPanel.vue'; + import StudioDetailsPanel from '../details/StudioDetailsPanel'; import { fitToScale, generatePdf } from '../../utils/helpers'; import ChannelCatalogFrontPage from './ChannelCatalogFrontPage'; @@ -33,7 +33,7 @@ name: 'ChannelCatalogPrint', components: { ChannelCatalogFrontPage, - DetailsPanel, + StudioDetailsPanel, }, provide: { printing: true, diff --git a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue index 0ff0b85751..9973c72cd6 100644 --- a/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/channel/ChannelDetailsModal.vue @@ -4,7 +4,10 @@ - +
- -
- -
+

- {{ isChannel ? _details.name : _details.title }} + {{ _details.name }}


- - - + + + + + {{ publishedDate }} + {{ $tr('unpublishedText') }} + + + + + + + +

- - - + - - + + - - + + - - + + - - + - + - - + + - - + + - + - @@ -216,8 +217,8 @@ inline /> - - + @@ -229,8 +230,8 @@ inline /> - - + @@ -242,34 +243,36 @@ inline /> - + - + - - + + - + - - + - - + - - +

- - -

- {{ getTitle(node) }} -

- -
-
-
+ {{ getTitle(node) }} +

+ +
@@ -364,21 +355,22 @@ import defaultsDeep from 'lodash/defaultsDeep'; import camelCase from 'lodash/camelCase'; import orderBy from 'lodash/orderBy'; - import { SCALE_TEXT, SCALE, CHANNEL_SIZE_DIVISOR } from './constants'; - import DetailsRow from './DetailsRow'; - import { CategoriesLookup, LevelsLookup } from 'shared/constants'; import { fileSizeMixin, constantsTranslationMixin, printingMixin, titleMixin, metadataTranslationMixin, - } from 'shared/mixins'; - import LoadingText from 'shared/views/LoadingText'; - import ExpandableList from 'shared/views/ExpandableList'; - import ContentNodeIcon from 'shared/views/ContentNodeIcon'; - import Thumbnail from 'shared/views/files/Thumbnail'; - import CopyToken from 'shared/views/CopyToken'; + } from '../../mixins'; + import { CategoriesLookup, LevelsLookup } from '../../constants'; + import ContentNodeIcon from '../ContentNodeIcon'; + import ExpandableList from '../ExpandableList'; + import StudioChip from '../StudioChip'; + import StudioLargeLoader from '../StudioLargeLoader'; + import { SCALE_TEXT, SCALE, CHANNEL_SIZE_DIVISOR } from './constants'; + import StudioDetailsRow from './StudioDetailsRow'; + import StudioThumbnail from 'shared/views/files/StudioThumbnail'; + import StudioCopyToken from 'shared/views/StudioCopyToken'; const DEFAULT_DETAILS = { name: '', @@ -414,14 +406,15 @@ }; export default { - name: 'DetailsPanel', + name: 'StudioDetailsPanel', components: { - LoadingText, + StudioLargeLoader, ContentNodeIcon, ExpandableList, - CopyToken, - DetailsRow, - Thumbnail, + StudioCopyToken, + StudioDetailsRow, + StudioChip, + StudioThumbnail, }, mixins: [ fileSizeMixin, @@ -439,10 +432,6 @@ type: Object, required: true, }, - isChannel: { - type: Boolean, - default: true, - }, loading: { type: Boolean, default: true, @@ -460,14 +449,11 @@ return '---'; }, publishedDate() { - if (this.isChannel) { - return this.$formatDate(this._details.last_published, { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - } - return ''; + return this.$formatDate(this._details.last_published, { + year: 'numeric', + month: 'long', + day: 'numeric', + }); }, sizeText() { const size = this._details.resource_size; @@ -543,21 +529,12 @@ return this.categories.join(', '); }, }, - mounted() { - if (!this.isChannel) { - // Track node details view when not a channel-- is this happening? - this.$analytics.trackAction('node_details', 'View', { - id: this._details.id, - }); - } - }, methods: { channelUrl(channel) { return window.Urls.channel(channel.id); }, }, $trs: { - /* eslint-disable kolibri/vue-no-unused-translations */ sizeHeading: 'Channel size', sizeText: '{text} ({size})', resourceHeading: 'Total resources', @@ -587,7 +564,6 @@ [SCALE_TEXT.VERY_LARGE]: 'Very large', containsContentHeading: 'Contains content from', sampleFromChannelHeading: 'Sample content from this channel', - sampleFromTopicHeading: 'Sample content from this topic', tokenHeading: 'Channel token', publishedHeading: 'Published on', currentVersionHeading: 'Published version', @@ -609,78 +585,62 @@ } } - .v-toolbar__title { - font-weight: bold; + .resource-list { + max-width: 350px; + margin: 8px 0; } - .draft-header { - color: gray; + .resource-item { + margin: 12px 0; } - .subheader { - margin-top: 20px; - margin-bottom: 0; - font-size: 10pt !important; - font-weight: bold; - color: gray; + .resource-icon { + margin-right: 8px; } - .detail-value { - font-size: 14pt; + .chip { + padding: 16px; font-weight: bold; } - .tag { - padding: 0 8px; - font-weight: bold; - background-color: var(--v-grey-lighten3); - border-radius: 10px; - } - - .kind-table { - max-width: 350px; - font-size: 12pt; - - ::v-deep tr { - border-top: 0 !important; - - &:hover { - background: transparent !important; - } - } - - td { - height: 36px; - font-size: 12pt; - } + .license-chip-wrapper { + cursor: pointer; } .preview-row { + display: flex; + gap: 16px; + align-items: center; margin: 16px 0; &:first-child { margin-top: 0; } + } + + .source-thumbnail { + flex-shrink: 0; + width: 150px; + } - a { - margin: 0 8px; - font-weight: bold; - text-decoration: none; + .channel-name { + font-weight: bold; + } - span { - text-decoration: underline; - } - } + .sample-heading { + margin-top: 28px; + margin-bottom: 8px; + font-size: 16px; + font-weight: bold; + } - .source-thumbnail { - flex-grow: 0; - width: 150px; - border: 1px solid var(--v-grey-lighten3); - } + .sample-nodes { + margin-top: 28px; } - .sample-nodes .v-card__text { - max-width: 800px; + .sample-node-title { + margin-top: 12px; + margin-bottom: 42px; font-weight: bold; word-break: break-word; } 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..2e53dc5109 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/details/StudioDetailsRow.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/files/StudioThumbnail.vue b/contentcuration/contentcuration/frontend/shared/views/files/StudioThumbnail.vue new file mode 100644 index 0000000000..5cea71be38 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/files/StudioThumbnail.vue @@ -0,0 +1,126 @@ + + + + + + +